44 Commits

Author SHA1 Message Date
kbe
5105964b39 feat: improve seo urls? 2025-09-06 01:44:48 +02:00
kbe
fa99a167a5 style: link content 2025-09-06 00:37:25 +02:00
kbe
9b33b73bb4 style: Clean up whitespace in tickets controller
- Remove extra blank lines and trailing spaces
- Improve code formatting consistency
- No functional changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 00:36:25 +02:00
kbe
bc47027c22 refactor: Convert ticket views to use only Tailwind CSS
- Rewrite ticket show view to use pure Tailwind CSS classes
- Update color scheme from gray-* to slate-* for modern look
- Replace indigo gradients with violet for better consistency
- Enhance spacing, typography, and visual hierarchy
- Add ticket_view route and controller action for PDF-like display
- Implement responsive QR code display with proper sizing
- Update status badge colors for better semantic meaning
- Improve accessibility with better button layouts and focus states

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 00:33:24 +02:00
kbe
7ef934d8a8 fix: Replace Prawn with Grover for PDF ticket generation
- Replace Prawn PDF generation with Grover (Chrome headless) for better compatibility
- Add HTML-based ticket template with embedded CSS styling
- Implement robust Grover loading with fallback to HTML download
- Add QR code generation methods to Ticket model
- Remove legacy TicketPdfGenerator service and tests
- Update PDF generation in TicketsController with proper error handling

The new implementation provides:
- Better HTML/CSS rendering for ticket layouts
- More reliable PDF generation using Chrome engine
- Fallback mechanism for better user experience
- Cleaner separation of template rendering and PDF conversion

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 00:04:02 +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
kbe
a0e53325f7 Translate links and buttons in hardcoded french 2025-09-05 00:15:59 +02:00
kbe
61079c8171 docs: Add comprehensive invoice system implementation guide
- Document various invoice generation approaches (PDF, HTML-to-PDF, Stripe)
- Compare Stripe Payment Intents vs Invoicing vs Checkout Sessions
- Provide complete code implementation with models, controllers, services
- Include phase-by-phase implementation strategy for current use case
- Add testing, security, and deployment guidelines
- Recommend hybrid approach: keep current checkout + post-payment invoices

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 02:18:50 +02:00
kbe
e1edc1afcd fix: Re-enable ticket-selection Stimulus controller registration
- Uncomment ticket-selection controller registration in JavaScript index
- Ensure ticket selection functionality works properly on event pages
- Fix controller not being available for ticket quantity management
- Required for proper cart functionality and checkout flow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:44:17 +02:00
kbe
bd6c0d5ed8 refactor: Remove legacy checkout methods from EventsController
- Remove checkout, process_names, and download_ticket methods
- Remove process_payment private method with complex Stripe logic
- Remove StripeConcern include and related authentication requirements
- Simplify EventsController to focus only on event display
- All checkout functionality now handled by OrdersController
- Clean up before_actions to match remaining functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:44:00 +02:00
kbe
5fc790cd42 fix: Resolve QR code generation errors in ticket PDF creation
- Add validation in TicketPdfGenerator to ensure QR code data integrity
- Use compact() to remove nil values from QR code data hash
- Add error handling in Ticket#generate_qr_code with fallback generation
- Validate QR code data before passing to RQRCode library
- Add proper error logging for QR code generation failures
- Prevent "data must be a String, QRSegment, or an Array" errors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:43:40 +02:00
kbe
ec5095d372 fix: Resolve Stripe checkout button loading issues
- Add proper Stripe library loading checks to prevent ReferenceError
- Implement retry logic for Stripe library initialization
- Add comprehensive debugging console logs for troubleshooting
- Ensure DOM ready state handling for Turbo compatibility
- Fix async loading race conditions between Stripe CDN and local script
- Add proper error handling for checkout button initialization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:43:32 +02:00
kbe
31f5d2188d refactor: Clean up TicketsController after order migration
- Remove unused 'new' and 'create' methods moved to OrdersController
- Update controller documentation to reflect new purpose as legacy redirect handler
- Remove unused private methods (ticket_params)
- Keep only legacy redirect methods for backward compatibility
- Update before_actions to match remaining functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:43:23 +02:00
kbe
e866e259bb fix: Update event flow to use new event-scoped order routes
- Update events/show form to use event_order_new_path instead of order_new_path
- Fix JavaScript redirect in ticket_selection_controller.js to use event-scoped URL
- Ensure proper event context is maintained throughout the order flow
- Resolve routing issues that caused "Commande non trouvée" errors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:43:13 +02:00
kbe
54e99c2f7e feat: Enhance orders/new view with integrated name collection form
- Add breadcrumb navigation for better UX
- Combine order summary and ticket name collection into single page
- Add comprehensive name collection form for each ticket
- Update form to submit to event-scoped order creation route
- Improve visual design with proper sections and styling
- Remove need for separate tickets controller flow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:43:04 +02:00
kbe
3ba5710d8f refactor: Move order creation to event-scoped routes and OrdersController
- Add event-scoped order routes: GET/POST /events/:slug.:id/orders
- Move ticket name collection and order creation logic from TicketsController to OrdersController
- Update OrdersController#new to handle both order summary and name collection
- Add OrdersController#create with full order and ticket creation logic
- Add set_event and order_params methods to OrdersController
- Maintain RESTful design with proper event context

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:42:54 +02:00
kbe
0f6d75b1e8 fix: Resolve tickets controller Event lookup issues
- Fix Event attribute name from starts_at to start_time in orders/new view
- Update TicketsController#set_event to use session[:event_id] as fallback when params[:id] is not available
- Remove duplicate Event.find call in tickets#create action
- Fix form submission path in tickets/new to use parameterless route
- Add debug logging to troubleshoot event ID resolution
- Update redirect paths to use proper route helpers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 01:12:59 +02:00
kbe
ee4399aa46 fix: CSS asset loading and convert footer to Tailwind
- Fix asset loading issues by removing commented footer.css import
- Convert footer component from custom CSS to Tailwind classes
- Add dark background styling to footer wrapper
- Maintain responsive grid layout and hover effects
- Remove unused CSS styles and simplify asset pipeline

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 00:57:44 +02:00
kbe
839120f2f4 wip: OrdersController#new 2025-09-03 01:52:48 +02:00
kbe
6965eb89fd fix: Only increment payment attempts when user actually attempts payment
- Remove payment attempt increment from checkout page load
- Add new increment_payment_attempt action triggered only on pay button click
- Update checkout JavaScript to make AJAX call before Stripe redirect
- Add proper error handling and button state management
- Prevent inflated payment attempt counts from page refreshes

This ensures payment attempts accurately reflect actual payment tries rather than page visits.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 23:16:31 +02:00
kbe
0ba6634e99 fix: Correct ticket creation flow and home page availability check
- Fix incorrect route helper in tickets controller (order_checkout_path -> checkout_order_path)
- Add missing set_event before_action for create action
- Fix home page availability check to use ticket_types.available_quantity instead of tickets.quantity
- Update AGENT.md with ast-grep documentation for development tools

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 22:43:14 +02:00
kbe
ca81d2360c wip: order checkout 2025-09-02 02:56:23 +02:00
kbe
afe074c8a1 docs: Add how to implement an order system 2025-09-02 02:18:56 +02:00
kbe
e838e91162 ## Backend Implementation
Enhanced TicketType model with helper methods and better validations

So the full context is:

## Backend Implementation
- Enhanced TicketType model with helper methods and better validations
- New Promoter::TicketTypesController with full authorization
- Sales status tracking (draft, available, upcoming, expired, sold_out)
- New Promoter::TicketTypesController with full authorization
- Safe calculation methods preventing nil value errors
- Sales status tracking (draft, available, upcoming, expired, sold_out)

## Frontend Features
- Modern responsive UI with Tailwind CSS styling
- Interactive forms with Stimulus controller for dynamic calculations
- Revenue calculators showing potential, current, and remaining revenue
- Status indicators with appropriate colors and icons
- Buyer analytics and purchase history display

## JavaScript Enhancements
- New TicketTypeFormController for dynamic pricing calculations
- Real-time total updates as users type price/quantity
- Proper French currency formatting
- Form validation for minimum quantities based on existing sales

## Bug Fixes
 Fixed nil value errors in price_euros method when price_cents is nil
 Added defensive programming for all calculation methods
 Graceful handling of incomplete ticket types during creation
 Proper default values for new ticket type instances

## Files Added/Modified
- app/controllers/promoter/ticket_types_controller.rb (new)
- app/javascript/controllers/ticket_type_form_controller.js (new)
- app/views/promoter/ticket_types/*.html.erb (4 new view files)
- app/models/ticket_type.rb (enhanced with helper methods)
- config/routes.rb (added nested ticket_types routes)
- db/migrate/*_add_requires_id_to_ticket_types.rb (new migration)

## Integration
- Seamless integration with existing event management system
- Updated promoter event show page with ticket management link
- Proper scoping ensuring promoters only manage their own tickets
- Compatible with existing ticket purchasing and checkout flow
2025-09-01 00:03:35 +02:00
kbe
aa5dccb508 feat: Implement comprehensive event management system for promoters
This commit adds a complete event management interface allowing promoters to 
create, edit, and manage their events with full CRUD operations.

## Backend Features
- New Promoter::EventsController with full CRUD operations
- Event state management (draft, published, canceled, sold_out)
- User authorization system with can_manage_events? method
- Proper scoping to ensure users only see their own events

## Frontend Features  
- Modern responsive UI with Tailwind CSS styling
- Event listing with status indicators and quick actions
- Comprehensive event creation and editing forms
- Detailed event show page with metrics and management options
- Integration with main dashboard via promoter action buttons

## JavaScript Improvements
- Refactored inline JavaScript to dedicated Stimulus controller
- Auto-slug generation from event names with proper sanitization
- Improved code organization following Rails conventions

## Routes & Navigation
- Namespaced promoter routes under /promoter/
- RESTful endpoints with state management actions
- Proper breadcrumb navigation and user flow

## Files Added/Modified
- app/controllers/promoter/events_controller.rb (new)
- app/javascript/controllers/event_form_controller.js (new) 
- app/views/promoter/events/*.html.erb (4 new view files)
- app/models/user.rb (added authorization methods)
- app/views/pages/dashboard.html.erb (added promoter buttons)
- config/routes.rb (added promoter namespace)
- app/javascript/controllers/index.js (registered new controller)

🎯 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-08-31 19:22:19 +02:00
156 changed files with 12556 additions and 10003 deletions

View File

@@ -0,0 +1,738 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - AperoNight | Plateforme Événementielle Premium</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="aperonight_premium_light_theme.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans) !important;
background: var(--background) !important;
min-height: 100vh !important;
position: relative !important;
overflow-x: hidden !important;
color: var(--foreground) !important;
}
/* Light theme background patterns */
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 2px 2px, var(--dot-color) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.3;
z-index: 0;
animation: dotFlow 30s linear infinite;
}
body::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(90deg, transparent 48%, var(--connection-color) 50%, transparent 52%),
linear-gradient(0deg, transparent 48%, var(--connection-color) 50%, transparent 52%);
background-size: 100px 100px;
opacity: 0.12;
z-index: 0;
animation: connectionFlow 20s linear infinite;
}
@keyframes dotFlow {
0% { transform: translate(0, 0); }
100% { transform: translate(40px, 40px); }
}
@keyframes connectionFlow {
0% { transform: translate(0, 0); }
100% { transform: translate(100px, 100px); }
}
/* Page entrance orchestration */
.page-container {
animation: pageLoad 1000ms cubic-bezier(0.23, 1, 0.32, 1) forwards;
opacity: 0;
transform: translateY(50px);
}
@keyframes pageLoad {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Brand reveal animation */
.brand-container {
animation: brandReveal 1400ms ease-out 300ms forwards;
opacity: 0;
transform: scale(0.7);
}
@keyframes brandReveal {
to {
opacity: 1;
transform: scale(1);
}
}
/* Premium card elevation - light theme */
.login-card {
background: var(--glass-bg) !important;
backdrop-filter: var(--glass-backdrop) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--radius-2xl) !important;
box-shadow: var(--shadow-2xl) !important;
animation: cardElevate 800ms cubic-bezier(0.34, 1.56, 0.64, 1) 600ms forwards;
opacity: 0;
transform: translateY(40px);
transition: all 400ms ease-out;
position: relative;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(59, 130, 246, 0.05),
transparent
);
transition: left 0.6s ease;
}
.login-card:hover::before {
left: 100%;
}
.login-card:hover {
transform: var(--hover-lift) var(--hover-scale);
box-shadow: var(--shadow-2xl), var(--shadow-electric);
}
@keyframes cardElevate {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Professional input styling - light theme */
.input-group {
position: relative;
margin-bottom: 1.75rem;
}
.input-field {
width: 100%;
padding: 1.25rem 3.5rem 1.25rem 1.25rem;
border: 2px solid var(--input-border);
border-radius: var(--radius-lg);
background: var(--input);
color: var(--card-foreground);
font-size: 1rem;
font-weight: 500;
transition: all 250ms ease-out;
outline: none;
font-family: var(--font-sans);
}
.input-field:focus {
border-color: var(--primary);
box-shadow: var(--shadow-electric), var(--focus-ring);
transform: scale(1.01);
background: white;
}
.input-field:focus + .floating-label {
transform: translateY(-12px) scale(0.85);
color: var(--primary);
font-weight: 600;
}
.floating-label {
position: absolute;
left: 1.25rem;
top: 50%;
transform: translateY(-50%);
background: var(--input);
padding: 0 0.75rem;
color: var(--muted-foreground);
pointer-events: none;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 500;
font-family: var(--font-sans);
}
.input-field:not(:placeholder-shown) + .floating-label {
transform: translateY(-12px) scale(0.85);
background: white;
}
/* Security toggle with premium feel - light theme */
.security-toggle {
position: absolute;
right: 1.25rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--muted-foreground);
cursor: pointer;
transition: all 200ms ease-out;
opacity: 0.7;
padding: 0.5rem;
border-radius: var(--radius-sm);
}
.security-toggle:hover {
opacity: 1;
color: var(--primary);
background: var(--primary-light);
transform: translateY(-50%) rotate(180deg);
}
/* Flat button styling - light theme */
.login-button {
width: 100%;
padding: 1.25rem;
background: var(--primary) !important;
border: none;
border-radius: var(--radius-lg);
color: var(--primary-foreground);
font-size: 1.1rem;
font-weight: 700;
font-family: var(--font-display);
cursor: pointer;
transition: all 300ms ease-out;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: var(--shadow-md);
}
.login-button:hover {
transform: var(--hover-lift);
box-shadow: var(--shadow-lg);
background: var(--primary-hover) !important;
}
.login-button:active {
transform: scale(0.97);
}
/* Premium ripple effect */
.login-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
transition: width 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: translate(-50%, -50%);
}
.login-button:active::before {
width: 400px;
height: 400px;
}
/* Sophisticated checkbox - light theme */
.premium-checkbox {
appearance: none;
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--input-border);
border-radius: var(--radius-sm);
background: var(--input);
cursor: pointer;
position: relative;
transition: all 200ms ease-out;
}
.premium-checkbox:checked {
background: var(--primary);
border-color: var(--primary);
animation: securityCheck 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.premium-checkbox:checked::before {
content: '';
position: absolute;
top: 2px;
left: 5px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
@keyframes securityCheck {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Professional link styling - light theme */
.premium-link {
color: var(--accent);
text-decoration: none;
position: relative;
transition: all 250ms ease-out;
font-weight: 500;
}
.premium-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width 250ms ease-out;
}
.premium-link:hover {
color: var(--primary);
}
.premium-link:hover::after {
width: 100%;
}
/* Validation states - light theme */
.input-error {
border-color: var(--destructive) !important;
animation: errorShake 400ms cubic-bezier(0.36, 0, 0.66, -0.56);
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.input-success {
border-color: var(--success) !important;
box-shadow: 0 0 0 3px var(--success-light);
animation: validationSuccess 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes validationSuccess {
0% { transform: scale(1); }
50% { transform: scale(1.02); border-color: var(--success); }
100% { transform: scale(1); }
}
/* Premium loading states - light theme */
.skeleton {
background: var(--muted);
animation: skeletonPulse 2s ease-in-out infinite;
}
@keyframes skeletonPulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 0.4; }
}
/* Logo styling - light theme */
.logo-container {
position: relative;
}
.logo-glow {
display: none;
}
/* Trust indicators - light theme */
.trust-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--success-light);
color: var(--success);
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 600;
border: 1px solid var(--success);
transition: all 300ms ease-out;
}
.trust-badge:hover {
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
/* Professional footer - light theme */
.pro-footer {
background: rgba(59, 130, 246, 0.08);
backdrop-filter: blur(10px);
border-radius: var(--radius-lg);
padding: 1rem;
margin-top: 2rem;
border: 1px solid rgba(59, 130, 246, 0.1);
}
/* Light theme brand colors */
.brand-text-primary {
color: var(--primary) !important;
}
.brand-text-secondary {
color: var(--primary-dark) !important;
}
.brand-text-muted {
color: var(--muted-foreground) !important;
}
.footer-link {
color: var(--primary) !important;
opacity: 0.8;
}
.footer-link:hover {
opacity: 1;
color: var(--primary-hover) !important;
}
.status-text {
color: var(--muted-foreground) !important;
}
/* Responsive enhancements */
@media (max-width: 640px) {
.login-card {
margin: 1rem;
padding: 2rem 1.5rem;
}
.input-field {
padding: 1rem 3rem 1rem 1rem;
}
.login-button {
font-size: 1rem;
padding: 1rem;
}
}
/* Advanced accessibility */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.login-card {
border: 3px solid var(--primary);
}
.input-field:focus {
outline: 3px solid var(--primary);
}
}
</style>
</head>
<body>
<div class="page-container relative z-10 flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-lg">
<!-- Premium Brand Section - Light Theme -->
<div class="brand-container text-center mb-10">
<div class="relative inline-block mb-6">
<div class="logo-glow"></div>
<div class="relative w-20 h-20 mx-auto bg-blue-600 rounded-2xl flex items-center justify-center logo-container">
<i data-lucide="calendar-check" class="w-10 h-10 text-white"></i>
</div>
</div>
<h1 class="text-3xl font-bold brand-text-primary mb-2 font-display">AperoNight</h1>
<p class="brand-text-secondary text-lg font-medium mb-2">Plateforme Événementielle Premium</p>
<p class="brand-text-muted text-sm opacity-90">Connexion sécurisée • Interface professionnelle</p>
<div class="flex justify-center mt-4">
<div class="trust-badge">
<i data-lucide="shield-check" class="w-4 h-4"></i>
<span>Connexion Sécurisée</span>
</div>
</div>
</div>
<!-- Premium Login Card -->
<div class="login-card p-8">
<div class="text-center mb-8">
<h2 class="text-xl font-bold text-gray-800 mb-2 font-display">Accès Dashboard</h2>
<p class="text-gray-600 text-sm">Gérez vos événements en toute simplicité</p>
</div>
<form class="space-y-6">
<!-- Email professionnel -->
<div class="input-group">
<input
type="email"
class="input-field"
placeholder=" "
required
id="email"
>
<label class="floating-label" for="email">Email professionnel</label>
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
</div>
<!-- Mot de passe sécurisé -->
<div class="input-group">
<input
type="password"
class="input-field"
placeholder=" "
required
id="password"
>
<label class="floating-label" for="password">Mot de passe sécurisé</label>
<button type="button" class="security-toggle" onclick="togglePassword()">
<i data-lucide="lock" class="w-5 h-5"></i>
</button>
</div>
<!-- Options de connexion -->
<div class="flex items-center justify-between">
<label class="flex items-center space-x-3 cursor-pointer group">
<input type="checkbox" class="premium-checkbox" id="remember">
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition-colors">
Maintenir la connexion
</span>
</label>
<div class="flex items-center space-x-1 text-xs text-gray-500">
<i data-lucide="timer" class="w-3 h-3"></i>
<span>30 jours</span>
</div>
</div>
<!-- Bouton de connexion premium -->
<button type="submit" class="login-button group">
<span class="relative z-10 flex items-center justify-center gap-2">
<i data-lucide="log-in" class="w-5 h-5"></i>
Accéder au Dashboard
</span>
</button>
<!-- Options de récupération -->
<div class="text-center space-y-3">
<a href="#" class="premium-link text-sm">Mot de passe oublié ?</a>
<div class="flex items-center justify-center space-x-4 text-xs text-gray-500">
<span class="flex items-center gap-1">
<i data-lucide="smartphone" class="w-3 h-3"></i>
2FA disponible
</span>
<span class="flex items-center gap-1">
<i data-lucide="key" class="w-3 h-3"></i>
SSO Enterprise
</span>
</div>
</div>
</form>
</div>
<!-- Professional Footer - Light Theme -->
<div class="pro-footer text-center space-y-3">
<div class="flex items-center justify-center space-x-6 text-sm">
<a href="#" class="footer-link transition-colors flex items-center gap-1">
<i data-lucide="life-buoy" class="w-4 h-4"></i>
Support Pro
</a>
<a href="#" class="footer-link transition-colors flex items-center gap-1">
<i data-lucide="shield" class="w-4 h-4"></i>
Sécurité Renforcée
</a>
<a href="#" class="footer-link transition-colors flex items-center gap-1">
<i data-lucide="zap" class="w-4 h-4"></i>
API Premium
</a>
</div>
<p class="text-xs status-text">© 2024 AperoNight • Plateforme Événementielle Premium • Tous droits réservés</p>
<div class="flex items-center justify-center space-x-2 text-xs status-text">
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
Système opérationnel
</span>
<span></span>
<span>99.9% uptime</span>
<span></span>
<span>GDPR compliant</span>
</div>
</div>
</div>
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Enhanced password toggle
function togglePassword() {
const passwordField = document.getElementById('password');
const toggleIcon = document.querySelector('.security-toggle i');
if (passwordField.type === 'password') {
passwordField.type = 'text';
toggleIcon.setAttribute('data-lucide', 'unlock');
} else {
passwordField.type = 'password';
toggleIcon.setAttribute('data-lucide', 'lock');
}
lucide.createIcons();
}
// Professional form validation
const form = document.querySelector('form');
const emailField = document.getElementById('email');
const passwordField = document.getElementById('password');
form.addEventListener('submit', function(e) {
e.preventDefault();
// Reset states
emailField.classList.remove('input-error', 'input-success');
passwordField.classList.remove('input-error', 'input-success');
let isValid = true;
// Professional email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailField.value)) {
emailField.classList.add('input-error');
isValid = false;
showNotification('Email invalide', 'error');
} else {
emailField.classList.add('input-success');
}
// Secure password validation
if (passwordField.value.length < 8) {
passwordField.classList.add('input-error');
isValid = false;
showNotification('Mot de passe trop court (min. 8 caractères)', 'error');
} else {
passwordField.classList.add('input-success');
}
if (isValid) {
// Premium loading state
const button = document.querySelector('.login-button');
const originalContent = button.innerHTML;
button.innerHTML = `
<div class="flex items-center justify-center space-x-2">
<div class="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
<span>Connexion sécurisée...</span>
</div>
`;
setTimeout(() => {
showNotification('Connexion réussie ! Redirection...', 'success');
setTimeout(() => {
button.innerHTML = originalContent;
}, 1500);
}, 2000);
}
});
// Real-time validation
emailField.addEventListener('input', function() {
this.classList.remove('input-error', 'input-success');
});
passwordField.addEventListener('input', function() {
this.classList.remove('input-error', 'input-success');
});
// Professional notification system
function showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `
fixed top-4 right-4 z-50 p-4 rounded-lg shadow-2xl max-w-sm
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}
transform transition-all duration-300 ease-out translate-x-full
`;
notification.innerHTML = `
<div class="flex items-center space-x-2">
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}" class="w-5 h-5"></i>
<span class="font-medium">${message}</span>
</div>
`;
document.body.appendChild(notification);
lucide.createIcons();
setTimeout(() => {
notification.style.transform = 'translateX(0)';
}, 100);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
// Enhanced floating label behavior
document.querySelectorAll('.input-field').forEach(input => {
input.addEventListener('focus', function() {
this.nextElementSibling.style.background = 'white';
});
input.addEventListener('blur', function() {
if (!this.value) {
this.nextElementSibling.style.background = 'var(--input)';
}
});
});
// Professional interaction tracking
console.log('🌟 AperoNight Premium Light Login Interface Loaded');
console.log('✅ Security features: 2FA, SSO, GDPR compliance');
console.log('🎨 Theme: Professional Event Platform - Light Mode');
</script>
</body>
</html>

View File

@@ -0,0 +1,125 @@
:root {
/* AperoNight Premium Light Theme - Professional Event Platform */
/* Base Colors - Clean Light Background with Professional Accents */
--background: oklch(0.9800 0.0050 240);
--foreground: oklch(0.1500 0.0200 240);
--surface: oklch(0.9600 0.0080 240);
--surface-elevated: oklch(0.9400 0.0120 240);
/* Card & Dialog surfaces */
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1500 0.0200 240);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1500 0.0200 240);
/* Primary - Professional Electric Blue */
--primary: oklch(0.5200 0.2200 220);
--primary-foreground: oklch(0.9900 0.0050 220);
--primary-hover: oklch(0.4600 0.2400 220);
--primary-light: oklch(0.9200 0.1000 220);
--primary-dark: oklch(0.3800 0.2600 220);
/* Secondary - Sophisticated Light Gray */
--secondary: oklch(0.9200 0.0100 240);
--secondary-foreground: oklch(0.3000 0.0300 240);
--secondary-hover: oklch(0.8800 0.0150 240);
/* Accent - Vibrant Cyan (Events Energy) */
--accent: oklch(0.6500 0.2400 200);
--accent-foreground: oklch(0.9900 0.0050 200);
--accent-light: oklch(0.9400 0.1200 200);
--accent-dark: oklch(0.5000 0.2800 200);
/* Success - Event Success Green */
--success: oklch(0.6000 0.2000 140);
--success-foreground: oklch(0.9800 0.0100 140);
--success-light: oklch(0.9600 0.0800 140);
/* Warning - Premium Amber */
--warning: oklch(0.7200 0.1800 60);
--warning-foreground: oklch(0.2500 0.0400 60);
--warning-light: oklch(0.9600 0.0800 60);
/* Error - Professional Red */
--destructive: oklch(0.5600 0.2200 20);
--destructive-foreground: oklch(0.9800 0.0100 20);
--destructive-light: oklch(0.9600 0.1000 20);
/* Muted tones */
--muted: oklch(0.9400 0.0100 240);
--muted-foreground: oklch(0.5200 0.0300 240);
--muted-dark: oklch(0.8800 0.0200 240);
/* Borders and inputs */
--border: oklch(0.8800 0.0200 240);
--input: oklch(0.9800 0.0080 240);
--input-border: oklch(0.8600 0.0300 240);
--ring: oklch(0.5200 0.2200 220);
/* Typography - Premium Event Platform */
--font-sans: 'Inter', 'Plus Jakarta Sans', system-ui, sans-serif;
--font-display: 'Space Grotesk', 'Outfit', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing and layout */
--radius: 0.75rem;
--spacing: 1rem;
/* Light theme shadow system */
--shadow-xs: 0 1px 3px 0 hsl(240 15% 15% / 0.08), 0 1px 2px -1px hsl(240 15% 15% / 0.06);
--shadow-sm: 0 2px 6px -1px hsl(240 15% 15% / 0.10), 0 2px 4px -2px hsl(240 15% 15% / 0.08);
--shadow: 0 4px 8px -2px hsl(240 15% 15% / 0.12), 0 2px 4px -2px hsl(240 15% 15% / 0.08);
--shadow-md: 0 8px 16px -4px hsl(240 15% 15% / 0.14), 0 4px 6px -2px hsl(240 15% 15% / 0.10);
--shadow-lg: 0 16px 24px -4px hsl(240 15% 15% / 0.16), 0 8px 8px -4px hsl(240 15% 15% / 0.08);
--shadow-xl: 0 20px 32px -8px hsl(240 15% 15% / 0.18), 0 8px 16px -8px hsl(240 15% 15% / 0.10);
--shadow-2xl: 0 32px 64px -12px hsl(240 15% 15% / 0.22);
/* Subtle accent shadows for light theme */
--shadow-electric: 0 4px 16px -2px hsl(220 80% 60% / 0.15), 0 2px 8px -2px hsl(220 80% 60% / 0.10);
--shadow-accent: 0 4px 16px -2px hsl(200 80% 60% / 0.18), 0 2px 8px -2px hsl(200 80% 60% / 0.12);
/* Light theme gradients */
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
--gradient-background: linear-gradient(135deg,
oklch(0.9800 0.0050 240) 0%,
oklch(0.9600 0.0080 235) 25%,
oklch(0.9400 0.0120 230) 50%,
oklch(0.9600 0.0080 225) 75%,
oklch(0.9800 0.0050 220) 100%);
--gradient-card: linear-gradient(135deg,
oklch(1.0000 0 0) 0%,
oklch(0.9900 0.0050 235) 100%);
/* Light theme pattern overlays */
--grid-color: oklch(0.8500 0.0300 240);
--dot-color: oklch(0.8000 0.0400 220);
--connection-color: oklch(0.7500 0.0800 210);
/* Light glassmorphism */
--glass-bg: oklch(1.0000 0 0 / 0.85);
--glass-border: oklch(0.8800 0.0200 240 / 0.25);
--glass-backdrop: blur(16px) saturate(180%);
/* Professional interaction states */
--hover-lift: translateY(-2px);
--hover-scale: scale(1.02);
--focus-ring: 0 0 0 3px var(--ring);
/* Event-specific colors for light theme */
--event-vip: oklch(0.6800 0.2200 45);
--event-premium: oklch(0.5800 0.2000 280);
--event-standard: oklch(0.6200 0.1600 160);
--event-available: oklch(0.6000 0.1800 140);
--event-limited: oklch(0.7000 0.1800 50);
--event-sold-out: oklch(0.5800 0.2000 15);
/* Radius variations */
--radius-xs: calc(var(--radius) - 4px);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) + 8px);
--radius-2xl: calc(var(--radius) + 12px);
--radius-full: 9999px;
}

View File

@@ -0,0 +1,710 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - AperoNight | Plateforme Événementielle Premium</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="aperonight_premium_theme.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans) !important;
background: oklch(0.1200 0.0300 240) !important;
min-height: 100vh !important;
position: relative !important;
overflow-x: hidden !important;
color: var(--foreground) !important;
}
/* Advanced background patterns */
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 2px 2px, var(--dot-color) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.4;
z-index: 0;
animation: dotFlow 30s linear infinite;
}
body::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(90deg, transparent 48%, var(--connection-color) 50%, transparent 52%),
linear-gradient(0deg, transparent 48%, var(--connection-color) 50%, transparent 52%);
background-size: 100px 100px;
opacity: 0.15;
z-index: 0;
animation: connectionFlow 20s linear infinite;
}
@keyframes dotFlow {
0% { transform: translate(0, 0); }
100% { transform: translate(40px, 40px); }
}
@keyframes connectionFlow {
0% { transform: translate(0, 0); }
100% { transform: translate(100px, 100px); }
}
/* Page entrance orchestration */
.page-container {
animation: pageLoad 1000ms cubic-bezier(0.23, 1, 0.32, 1) forwards;
opacity: 0;
transform: translateY(50px);
}
@keyframes pageLoad {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Brand reveal animation */
.brand-container {
animation: brandReveal 1400ms ease-out 300ms forwards;
opacity: 0;
transform: scale(0.7);
}
@keyframes brandReveal {
to {
opacity: 1;
transform: scale(1);
}
}
/* Premium card elevation */
.login-card {
background: var(--glass-bg) !important;
backdrop-filter: var(--glass-backdrop) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--radius-2xl) !important;
box-shadow: var(--shadow-2xl) !important;
animation: cardElevate 800ms cubic-bezier(0.34, 1.56, 0.64, 1) 600ms forwards;
opacity: 0;
transform: translateY(40px);
transition: all 400ms ease-out;
position: relative;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
transition: left 0.6s ease;
}
.login-card:hover::before {
left: 100%;
}
.login-card:hover {
transform: var(--hover-lift) var(--hover-scale);
box-shadow: var(--shadow-2xl), var(--shadow-electric);
}
@keyframes cardElevate {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Professional input styling */
.input-group {
position: relative;
margin-bottom: 1.75rem;
}
.input-field {
width: 100%;
padding: 1.25rem 3.5rem 1.25rem 1.25rem;
border: 2px solid var(--input-border);
border-radius: var(--radius-lg);
background: var(--input);
color: var(--card-foreground);
font-size: 1rem;
font-weight: 500;
transition: all 250ms ease-out;
outline: none;
font-family: var(--font-sans);
}
.input-field:focus {
border-color: var(--primary);
box-shadow: var(--shadow-electric), var(--focus-ring);
transform: scale(1.01);
background: white;
}
.input-field:focus + .floating-label {
transform: translateY(-12px) scale(0.85);
color: var(--primary);
font-weight: 600;
}
.floating-label {
position: absolute;
left: 1.25rem;
top: 50%;
transform: translateY(-50%);
background: var(--input);
padding: 0 0.75rem;
color: var(--muted-foreground);
pointer-events: none;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 500;
font-family: var(--font-sans);
}
.input-field:not(:placeholder-shown) + .floating-label {
transform: translateY(-12px) scale(0.85);
background: white;
}
/* Security toggle with premium feel */
.security-toggle {
position: absolute;
right: 1.25rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--muted-foreground);
cursor: pointer;
transition: all 200ms ease-out;
opacity: 0.7;
padding: 0.5rem;
border-radius: var(--radius-sm);
}
.security-toggle:hover {
opacity: 1;
color: var(--primary);
background: var(--primary-light);
transform: translateY(-50%) rotate(180deg);
}
/* Flat button styling */
.login-button {
width: 100%;
padding: 1.25rem;
background: var(--primary) !important;
border: none;
border-radius: var(--radius-lg);
color: var(--primary-foreground);
font-size: 1.1rem;
font-weight: 700;
font-family: var(--font-display);
cursor: pointer;
transition: all 300ms ease-out;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: var(--shadow-md);
}
.login-button:hover {
transform: var(--hover-lift);
box-shadow: var(--shadow-lg);
background: var(--primary-hover) !important;
}
.login-button:active {
transform: scale(0.97);
}
/* Premium ripple effect */
.login-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
transition: width 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: translate(-50%, -50%);
}
.login-button:active::before {
width: 400px;
height: 400px;
}
/* Sophisticated checkbox */
.premium-checkbox {
appearance: none;
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--input-border);
border-radius: var(--radius-sm);
background: var(--input);
cursor: pointer;
position: relative;
transition: all 200ms ease-out;
}
.premium-checkbox:checked {
background: var(--primary);
border-color: var(--primary);
animation: securityCheck 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.premium-checkbox:checked::before {
content: '';
position: absolute;
top: 2px;
left: 5px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
@keyframes securityCheck {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Professional link styling */
.premium-link {
color: var(--accent);
text-decoration: none;
position: relative;
transition: all 250ms ease-out;
font-weight: 500;
}
.premium-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width 250ms ease-out;
}
.premium-link:hover {
color: var(--primary);
}
.premium-link:hover::after {
width: 100%;
}
/* Validation states */
.input-error {
border-color: var(--destructive) !important;
animation: errorShake 400ms cubic-bezier(0.36, 0, 0.66, -0.56);
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.input-success {
border-color: var(--success) !important;
box-shadow: 0 0 0 3px var(--success-light);
animation: validationSuccess 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes validationSuccess {
0% { transform: scale(1); }
50% { transform: scale(1.02); border-color: var(--success); }
100% { transform: scale(1); }
}
/* Premium loading states */
.skeleton {
background: var(--muted);
animation: skeletonPulse 2s ease-in-out infinite;
}
@keyframes skeletonPulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 0.4; }
}
/* Logo styling */
.logo-container {
position: relative;
}
.logo-glow {
display: none;
}
/* Trust indicators */
.trust-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--success-light);
color: var(--success);
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 600;
border: 1px solid var(--success);
transition: all 300ms ease-out;
}
.trust-badge:hover {
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
/* Professional footer */
.pro-footer {
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border-radius: var(--radius-lg);
padding: 1rem;
margin-top: 2rem;
}
/* Responsive enhancements */
@media (max-width: 640px) {
.login-card {
margin: 1rem;
padding: 2rem 1.5rem;
}
.input-field {
padding: 1rem 3rem 1rem 1rem;
}
.login-button {
font-size: 1rem;
padding: 1rem;
}
}
/* Advanced accessibility */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.login-card {
border: 3px solid var(--primary);
}
.input-field:focus {
outline: 3px solid var(--primary);
}
}
</style>
</head>
<body>
<div class="page-container relative z-10 flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-lg">
<!-- Premium Brand Section -->
<div class="brand-container text-center mb-10">
<div class="relative inline-block mb-6">
<div class="logo-glow"></div>
<div class="relative w-20 h-20 mx-auto bg-blue-600 rounded-2xl flex items-center justify-center logo-container">
<i data-lucide="calendar-check" class="w-10 h-10 text-white"></i>
</div>
</div>
<h1 class="text-3xl font-bold text-white mb-2 font-display">AperoNight</h1>
<p class="text-blue-200 text-lg font-medium mb-2">Plateforme Événementielle Premium</p>
<p class="text-blue-300 text-sm opacity-90">Connexion sécurisée • Interface professionnelle</p>
<div class="flex justify-center mt-4">
<div class="trust-badge">
<i data-lucide="shield-check" class="w-4 h-4"></i>
<span>Connexion Sécurisée</span>
</div>
</div>
</div>
<!-- Premium Login Card -->
<div class="login-card p-8">
<div class="text-center mb-8">
<h2 class="text-xl font-bold text-gray-800 mb-2 font-display">Accès Dashboard</h2>
<p class="text-gray-600 text-sm">Gérez vos événements en toute simplicité</p>
</div>
<form class="space-y-6">
<!-- Email professionnel -->
<div class="input-group">
<input
type="email"
class="input-field"
placeholder=" "
required
id="email"
>
<label class="floating-label" for="email">Email professionnel</label>
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
</div>
<!-- Mot de passe sécurisé -->
<div class="input-group">
<input
type="password"
class="input-field"
placeholder=" "
required
id="password"
>
<label class="floating-label" for="password">Mot de passe sécurisé</label>
<button type="button" class="security-toggle" onclick="togglePassword()">
<i data-lucide="lock" class="w-5 h-5"></i>
</button>
</div>
<!-- Options de connexion -->
<div class="flex items-center justify-between">
<label class="flex items-center space-x-3 cursor-pointer group">
<input type="checkbox" class="premium-checkbox" id="remember">
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition-colors">
Maintenir la connexion
</span>
</label>
<div class="flex items-center space-x-1 text-xs text-gray-500">
<i data-lucide="timer" class="w-3 h-3"></i>
<span>30 jours</span>
</div>
</div>
<!-- Bouton de connexion premium -->
<button type="submit" class="login-button group">
<span class="relative z-10 flex items-center justify-center gap-2">
<i data-lucide="log-in" class="w-5 h-5"></i>
Accéder au Dashboard
</span>
</button>
<!-- Options de récupération -->
<div class="text-center space-y-3">
<a href="#" class="premium-link text-sm">Mot de passe oublié ?</a>
<div class="flex items-center justify-center space-x-4 text-xs text-gray-500">
<span class="flex items-center gap-1">
<i data-lucide="smartphone" class="w-3 h-3"></i>
2FA disponible
</span>
<span class="flex items-center gap-1">
<i data-lucide="key" class="w-3 h-3"></i>
SSO Enterprise
</span>
</div>
</div>
</form>
</div>
<!-- Professional Footer -->
<div class="pro-footer text-center space-y-3">
<div class="flex items-center justify-center space-x-6 text-sm text-blue-200">
<a href="#" class="hover:text-white transition-colors flex items-center gap-1">
<i data-lucide="life-buoy" class="w-4 h-4"></i>
Support Pro
</a>
<a href="#" class="hover:text-white transition-colors flex items-center gap-1">
<i data-lucide="shield" class="w-4 h-4"></i>
Sécurité Renforcée
</a>
<a href="#" class="hover:text-white transition-colors flex items-center gap-1">
<i data-lucide="zap" class="w-4 h-4"></i>
API Premium
</a>
</div>
<p class="text-xs text-blue-300">© 2024 AperoNight • Plateforme Événementielle Premium • Tous droits réservés</p>
<div class="flex items-center justify-center space-x-2 text-xs text-blue-400">
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
Système opérationnel
</span>
<span></span>
<span>99.9% uptime</span>
<span></span>
<span>GDPR compliant</span>
</div>
</div>
</div>
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Enhanced password toggle
function togglePassword() {
const passwordField = document.getElementById('password');
const toggleIcon = document.querySelector('.security-toggle i');
if (passwordField.type === 'password') {
passwordField.type = 'text';
toggleIcon.setAttribute('data-lucide', 'unlock');
} else {
passwordField.type = 'password';
toggleIcon.setAttribute('data-lucide', 'lock');
}
lucide.createIcons();
}
// Professional form validation
const form = document.querySelector('form');
const emailField = document.getElementById('email');
const passwordField = document.getElementById('password');
form.addEventListener('submit', function(e) {
e.preventDefault();
// Reset states
emailField.classList.remove('input-error', 'input-success');
passwordField.classList.remove('input-error', 'input-success');
let isValid = true;
// Professional email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailField.value)) {
emailField.classList.add('input-error');
isValid = false;
showNotification('Email invalide', 'error');
} else {
emailField.classList.add('input-success');
}
// Secure password validation
if (passwordField.value.length < 8) {
passwordField.classList.add('input-error');
isValid = false;
showNotification('Mot de passe trop court (min. 8 caractères)', 'error');
} else {
passwordField.classList.add('input-success');
}
if (isValid) {
// Premium loading state
const button = document.querySelector('.login-button');
const originalContent = button.innerHTML;
button.innerHTML = `
<div class="flex items-center justify-center space-x-2">
<div class="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
<span>Connexion sécurisée...</span>
</div>
`;
setTimeout(() => {
showNotification('Connexion réussie ! Redirection...', 'success');
setTimeout(() => {
button.innerHTML = originalContent;
}, 1500);
}, 2000);
}
});
// Real-time validation
emailField.addEventListener('input', function() {
this.classList.remove('input-error', 'input-success');
});
passwordField.addEventListener('input', function() {
this.classList.remove('input-error', 'input-success');
});
// Professional notification system
function showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `
fixed top-4 right-4 z-50 p-4 rounded-lg shadow-2xl max-w-sm
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}
transform transition-all duration-300 ease-out translate-x-full
`;
notification.innerHTML = `
<div class="flex items-center space-x-2">
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}" class="w-5 h-5"></i>
<span class="font-medium">${message}</span>
</div>
`;
document.body.appendChild(notification);
lucide.createIcons();
setTimeout(() => {
notification.style.transform = 'translateX(0)';
}, 100);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
// Enhanced floating label behavior
document.querySelectorAll('.input-field').forEach(input => {
input.addEventListener('focus', function() {
this.nextElementSibling.style.background = 'white';
});
input.addEventListener('blur', function() {
if (!this.value) {
this.nextElementSibling.style.background = 'var(--input)';
}
});
});
// Professional interaction tracking
console.log('🚀 AperoNight Premium Login Interface Loaded');
console.log('✅ Security features: 2FA, SSO, GDPR compliance');
console.log('🎨 Theme: Professional Event Platform');
</script>
</body>
</html>

View File

@@ -0,0 +1,125 @@
:root {
/* AperoNight Premium Theme - Telecom Inspired */
/* Base Colors - Sophisticated Navy & Electric Accents */
--background: oklch(0.1200 0.0300 240);
--foreground: oklch(0.9500 0.0100 240);
--surface: oklch(0.1600 0.0400 240);
--surface-elevated: oklch(0.2000 0.0500 240);
/* Card & Dialog surfaces */
--card: oklch(0.9800 0.0100 240);
--card-foreground: oklch(0.1500 0.0200 240);
--popover: oklch(0.9800 0.0100 240);
--popover-foreground: oklch(0.1500 0.0200 240);
/* Primary - Premium Electric Blue */
--primary: oklch(0.5500 0.2400 220);
--primary-foreground: oklch(0.9800 0.0100 220);
--primary-hover: oklch(0.4800 0.2600 220);
--primary-light: oklch(0.8500 0.1200 220);
--primary-dark: oklch(0.3500 0.2800 220);
/* Secondary - Sophisticated Slate */
--secondary: oklch(0.8800 0.0200 240);
--secondary-foreground: oklch(0.2500 0.0300 240);
--secondary-hover: oklch(0.8200 0.0300 240);
/* Accent - Vibrant Cyan (Events Energy) */
--accent: oklch(0.6800 0.2600 200);
--accent-foreground: oklch(0.9800 0.0100 200);
--accent-light: oklch(0.8800 0.1400 200);
--accent-dark: oklch(0.4500 0.3000 200);
/* Success - Event Success Green */
--success: oklch(0.6200 0.2200 140);
--success-foreground: oklch(0.9600 0.0200 140);
--success-light: oklch(0.9200 0.1000 140);
/* Warning - Premium Gold */
--warning: oklch(0.7500 0.2000 60);
--warning-foreground: oklch(0.2000 0.0300 60);
--warning-light: oklch(0.9400 0.1000 60);
/* Error - Professional Red */
--destructive: oklch(0.5800 0.2400 20);
--destructive-foreground: oklch(0.9700 0.0200 20);
--destructive-light: oklch(0.9300 0.1200 20);
/* Muted tones */
--muted: oklch(0.8800 0.0200 240);
--muted-foreground: oklch(0.4800 0.0400 240);
--muted-dark: oklch(0.7500 0.0300 240);
/* Borders and inputs */
--border: oklch(0.8400 0.0300 240);
--input: oklch(0.9600 0.0200 240);
--input-border: oklch(0.8200 0.0400 240);
--ring: oklch(0.5500 0.2400 220);
/* Typography - Premium Event Platform */
--font-sans: 'Inter', 'Plus Jakarta Sans', system-ui, sans-serif;
--font-display: 'Space Grotesk', 'Outfit', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing and layout */
--radius: 0.75rem;
--spacing: 1rem;
/* Premium shadow system */
--shadow-xs: 0 1px 3px 0 hsl(240 30% 8% / 0.08), 0 1px 2px -1px hsl(240 30% 8% / 0.06);
--shadow-sm: 0 2px 6px -1px hsl(240 30% 8% / 0.10), 0 2px 4px -2px hsl(240 30% 8% / 0.08);
--shadow: 0 4px 8px -2px hsl(240 30% 8% / 0.12), 0 2px 4px -2px hsl(240 30% 8% / 0.08);
--shadow-md: 0 8px 16px -4px hsl(240 30% 8% / 0.14), 0 4px 6px -2px hsl(240 30% 8% / 0.10);
--shadow-lg: 0 16px 24px -4px hsl(240 30% 8% / 0.16), 0 8px 8px -4px hsl(240 30% 8% / 0.08);
--shadow-xl: 0 20px 32px -8px hsl(240 30% 8% / 0.20), 0 8px 16px -8px hsl(240 30% 8% / 0.12);
--shadow-2xl: 0 32px 64px -12px hsl(240 30% 8% / 0.25);
/* Electric/Glow shadows for premium effects */
--shadow-electric: 0 4px 16px -2px hsl(220 100% 70% / 0.20), 0 2px 8px -2px hsl(220 100% 70% / 0.15);
--shadow-accent: 0 4px 16px -2px hsl(200 100% 70% / 0.25), 0 2px 8px -2px hsl(200 100% 70% / 0.20);
/* Premium gradients */
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
--gradient-background: linear-gradient(135deg,
oklch(0.1200 0.0300 240) 0%,
oklch(0.1000 0.0400 235) 25%,
oklch(0.0800 0.0500 230) 50%,
oklch(0.1000 0.0400 225) 75%,
oklch(0.1200 0.0300 220) 100%);
--gradient-card: linear-gradient(135deg,
oklch(0.9900 0.0100 240) 0%,
oklch(0.9700 0.0200 235) 100%);
/* Tech pattern overlays */
--grid-color: oklch(0.3000 0.0500 240);
--dot-color: oklch(0.2500 0.0600 220);
--connection-color: oklch(0.4000 0.1200 210);
/* Glass morphism for premium feel */
--glass-bg: oklch(0.9800 0.0100 240 / 0.80);
--glass-border: oklch(0.8500 0.0300 240 / 0.30);
--glass-backdrop: blur(16px) saturate(200%);
/* Professional states */
--hover-lift: translateY(-2px);
--hover-scale: scale(1.02);
--focus-ring: 0 0 0 3px var(--ring);
/* Event-specific colors */
--event-vip: oklch(0.6500 0.2500 45);
--event-premium: oklch(0.5500 0.2200 280);
--event-standard: oklch(0.6000 0.1800 160);
--event-available: oklch(0.6200 0.2000 140);
--event-limited: oklch(0.7200 0.2000 50);
--event-sold-out: oklch(0.5500 0.2200 15);
/* Radius variations */
--radius-xs: calc(var(--radius) - 4px);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) + 8px);
--radius-2xl: calc(var(--radius) + 12px);
--radius-full: 9999px;
}

View File

@@ -1,398 +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 - Modern Card-Based 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&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.glassmorphism {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.metric-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.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
</style>
</head>
<body class="min-h-screen gradient-bg">
<!-- Main Container -->
<div class="min-h-screen p-4 md:p-6 lg:p-8">
<div class="max-w-7xl mx-auto">
<!-- Header Section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-4xl font-bold text-white mb-2">Tableau de bord</h1>
<p class="text-white/80">Gérez vos événements et réservations</p>
</div>
<div class="flex items-center space-x-4">
<button class="glassmorphism px-4 py-2 rounded-xl text-gray-800 font-medium hover:bg-white/50 transition-all">
<i data-lucide="settings" class="w-5 h-5 inline mr-2"></i>
Paramètres
</button>
<button class="bg-white/20 backdrop-blur-lg px-4 py-2 rounded-xl text-white font-medium hover:bg-white/30 transition-all">
<i data-lucide="plus" class="w-5 h-5 inline mr-2"></i>
Nouveau
</button>
</div>
</div>
<!-- Metrics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Mes réservations -->
<div class="metric-card p-6 rounded-2xl card-hover">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-gray-600 mb-1">Mes réservations</p>
<p class="text-3xl font-bold text-gray-900 mb-2">5</p>
<div class="flex items-center text-sm">
<span class="text-green-600 font-medium">+12%</span>
<span class="text-gray-500 ml-1">ce mois</span>
</div>
</div>
<div class="p-3 bg-green-100 rounded-xl">
<i data-lucide="calendar-check" class="w-6 h-6 text-green-600"></i>
</div>
</div>
</div>
<!-- Événements aujourd'hui -->
<div class="metric-card p-6 rounded-2xl card-hover">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-gray-600 mb-1">Événements aujourd'hui</p>
<p class="text-3xl font-bold text-gray-900 mb-2">3</p>
<div class="flex items-center text-sm">
<span class="text-blue-600 font-medium">2 nouveaux</span>
</div>
</div>
<div class="p-3 bg-blue-100 rounded-xl">
<i data-lucide="clock" class="w-6 h-6 text-blue-600"></i>
</div>
</div>
</div>
<!-- Événements demain -->
<div class="metric-card p-6 rounded-2xl card-hover">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-gray-600 mb-1">Événements demain</p>
<p class="text-3xl font-bold text-gray-900 mb-2">7</p>
<div class="flex items-center text-sm">
<span class="text-purple-600 font-medium">Populaire</span>
</div>
</div>
<div class="p-3 bg-purple-100 rounded-xl">
<i data-lucide="calendar" class="w-6 h-6 text-purple-600"></i>
</div>
</div>
</div>
<!-- À venir -->
<div class="metric-card p-6 rounded-2xl card-hover">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-gray-600 mb-1">À venir</p>
<p class="text-3xl font-bold text-gray-900 mb-2">15</p>
<div class="flex items-center text-sm">
<span class="text-orange-600 font-medium">Cette semaine</span>
</div>
</div>
<div class="p-3 bg-orange-100 rounded-xl">
<i data-lucide="trending-up" class="w-6 h-6 text-orange-600"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Draft Tickets Alert -->
<div class="glassmorphism rounded-2xl p-6 mb-8 border-l-4 border-orange-400 card-hover">
<div class="flex items-start space-x-4">
<div class="p-3 bg-orange-100 rounded-xl">
<i data-lucide="alert-triangle" class="w-6 h-6 text-orange-600"></i>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Billets en attente de paiement</h3>
<p class="text-gray-600 mb-4">Vous avez des billets qui nécessitent un paiement</p>
<!-- Draft Ticket Item -->
<div class="bg-white/80 rounded-xl p-4 mb-4">
<div class="flex items-start 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 flex items-center">
<i data-lucide="calendar" class="w-4 h-4 mr-1"></i>
15 Septembre 2024 à 20:00
</p>
</div>
<span class="bg-orange-100 text-orange-800 px-3 py-1 rounded-full text-sm font-medium">
2 billets
</span>
</div>
<div class="space-y-2 mb-4">
<div class="flex items-center justify-between text-sm bg-gray-50 rounded-lg p-3">
<span><strong>Standard</strong> - Marie Dubois</span>
<div class="flex items-center space-x-2">
<span class="text-gray-500">Expire dans 25min</span>
<span class="font-semibold">€35</span>
</div>
</div>
<div class="flex items-center justify-between text-sm bg-gray-50 rounded-lg p-3">
<span><strong>Standard</strong> - Pierre Martin</span>
<div class="flex items-center space-x-2">
<span class="text-gray-500">Expire dans 25min</span>
<span class="font-semibold">€35</span>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="text-sm text-gray-600">
Tentatives: 1/3
<span class="text-orange-600 font-medium ml-2">⚠️ Expire bientôt</span>
</div>
<button class="bg-gradient-to-r from-orange-500 to-orange-600 text-white px-6 py-2 rounded-xl font-medium hover:from-orange-600 hover:to-orange-700 transition-all transform hover:scale-105">
Reprendre le paiement
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- My Booked Events -->
<div class="glassmorphism rounded-2xl p-6 card-hover">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-900">Mes événements réservés</h2>
<button class="text-blue-600 hover:text-blue-700 text-sm font-medium">Voir tout</button>
</div>
<div class="space-y-4">
<!-- Event Item -->
<div class="flex items-center space-x-4 p-4 bg-white/60 rounded-xl hover:bg-white/80 transition-all">
<div class="w-12 h-12 bg-gradient-to-r from-purple-400 to-purple-600 rounded-xl flex items-center justify-center">
<i data-lucide="music" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-900">Concert Rock Alternative</h3>
<p class="text-sm text-gray-600">Aujourd'hui 21:00 • Salle Pleyel</p>
</div>
<div class="text-right">
<span class="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">Confirmé</span>
</div>
</div>
<div class="flex items-center space-x-4 p-4 bg-white/60 rounded-xl hover:bg-white/80 transition-all">
<div class="w-12 h-12 bg-gradient-to-r from-blue-400 to-blue-600 rounded-xl flex items-center justify-center">
<i data-lucide="users" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-900">Networking Tech</h3>
<p class="text-sm text-gray-600">Demain 19:00 • WeWork République</p>
</div>
<div class="text-right">
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-medium">À venir</span>
</div>
</div>
<div class="flex items-center space-x-4 p-4 bg-white/60 rounded-xl hover:bg-white/80 transition-all">
<div class="w-12 h-12 bg-gradient-to-r from-green-400 to-green-600 rounded-xl flex items-center justify-center">
<i data-lucide="coffee" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-900">Brunch du Dimanche</h3>
<p class="text-sm text-gray-600">Dimanche 11:00 • Café de Flore</p>
</div>
<div class="text-right">
<span class="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-medium">En attente</span>
</div>
</div>
</div>
<div class="mt-6 text-center">
<button class="text-blue-600 hover:text-blue-700 font-medium text-sm hover:underline">
Voir tous mes événements →
</button>
</div>
</div>
<!-- Today's Events -->
<div class="glassmorphism rounded-2xl p-6 card-hover">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-900">Événements du jour</h2>
<button class="text-blue-600 hover:text-blue-700 text-sm font-medium">Voir tout</button>
</div>
<div class="space-y-4">
<!-- Event Item -->
<div class="flex items-center space-x-4 p-4 bg-white/60 rounded-xl hover:bg-white/80 transition-all cursor-pointer">
<div class="w-16 h-16 bg-gradient-to-r from-red-400 to-pink-600 rounded-xl flex items-center justify-center">
<i data-lucide="star" class="w-8 h-8 text-white"></i>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-900">Festival de Cinéma</h3>
<p class="text-sm text-gray-600 mb-1">MK2 Bibliothèque • 20:30</p>
<div class="flex items-center space-x-2">
<span class="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-medium">Populaire</span>
<span class="text-xs text-gray-500">€25</span>
</div>
</div>
<button class="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:from-blue-600 hover:to-blue-700 transition-all">
Réserver
</button>
</div>
<div class="flex items-center space-x-4 p-4 bg-white/60 rounded-xl hover:bg-white/80 transition-all cursor-pointer">
<div class="w-16 h-16 bg-gradient-to-r from-yellow-400 to-orange-600 rounded-xl flex items-center justify-center">
<i data-lucide="utensils" class="w-8 h-8 text-white"></i>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-900">Cours de Cuisine</h3>
<p class="text-sm text-gray-600 mb-1">École Ducasse • 14:00</p>
<div class="flex items-center space-x-2">
<span class="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-medium">Limité</span>
<span class="text-xs text-gray-500">€85</span>
</div>
</div>
<button class="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:from-blue-600 hover:to-blue-700 transition-all">
Réserver
</button>
</div>
<div class="flex items-center space-x-4 p-4 bg-white/60 rounded-xl hover:bg-white/80 transition-all cursor-pointer">
<div class="w-16 h-16 bg-gradient-to-r from-teal-400 to-cyan-600 rounded-xl flex items-center justify-center">
<i data-lucide="camera" class="w-8 h-8 text-white"></i>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-900">Exposition Photo</h3>
<p class="text-sm text-gray-600 mb-1">Galerie Perrotin • 10:00</p>
<div class="flex items-center space-x-2">
<span class="bg-teal-100 text-teal-800 px-2 py-1 rounded-full text-xs font-medium">Gratuit</span>
</div>
</div>
<button class="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:from-blue-600 hover:to-blue-700 transition-all">
Voir
</button>
</div>
</div>
</div>
</div>
<!-- Bottom Section -->
<div class="mt-8">
<div class="glassmorphism rounded-2xl p-6 card-hover">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-900">Autres événements à venir</h2>
<div class="flex items-center space-x-4">
<button class="text-gray-600 hover:text-gray-800">
<i data-lucide="filter" class="w-5 h-5"></i>
</button>
<button class="text-gray-600 hover:text-gray-800">
<i data-lucide="search" class="w-5 h-5"></i>
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Event Card -->
<div class="bg-white/60 rounded-xl p-4 hover:bg-white/80 transition-all cursor-pointer">
<div class="w-full h-32 bg-gradient-to-r from-purple-400 to-purple-600 rounded-lg mb-4 flex items-center justify-center">
<i data-lucide="music" class="w-12 h-12 text-white"></i>
</div>
<h3 class="font-semibold text-gray-900 mb-2">Concert Électro</h3>
<p class="text-sm text-gray-600 mb-3">Samedi 21 Sept • Berghain</p>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-gray-900">€45</span>
<button class="bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
Réserver
</button>
</div>
</div>
<div class="bg-white/60 rounded-xl p-4 hover:bg-white/80 transition-all cursor-pointer">
<div class="w-full h-32 bg-gradient-to-r from-green-400 to-teal-600 rounded-lg mb-4 flex items-center justify-center">
<i data-lucide="leaf" class="w-12 h-12 text-white"></i>
</div>
<h3 class="font-semibold text-gray-900 mb-2">Marché Bio</h3>
<p class="text-sm text-gray-600 mb-3">Dimanche 22 Sept • Place des Vosges</p>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-green-600">Gratuit</span>
<button class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 transition-colors">
Voir
</button>
</div>
</div>
<div class="bg-white/60 rounded-xl p-4 hover:bg-white/80 transition-all cursor-pointer">
<div class="w-full h-32 bg-gradient-to-r from-orange-400 to-red-600 rounded-lg mb-4 flex items-center justify-center">
<i data-lucide="book-open" class="w-12 h-12 text-white"></i>
</div>
<h3 class="font-semibold text-gray-900 mb-2">Salon du Livre</h3>
<p class="text-sm text-gray-600 mb-3">Lundi 23 Sept • Grand Palais</p>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-gray-900">€15</span>
<button class="bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-700 transition-colors">
Réserver
</button>
</div>
</div>
</div>
<!-- Pagination -->
<div class="flex items-center justify-center space-x-2 mt-8">
<button class="px-3 py-2 rounded-lg bg-white/60 text-gray-600 hover:bg-white/80 transition-all">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</button>
<button class="px-4 py-2 rounded-lg bg-blue-600 text-white font-medium">1</button>
<button class="px-4 py-2 rounded-lg bg-white/60 text-gray-600 hover:bg-white/80 transition-all">2</button>
<button class="px-4 py-2 rounded-lg bg-white/60 text-gray-600 hover:bg-white/80 transition-all">3</button>
<button class="px-3 py-2 rounded-lg bg-white/60 text-gray-600 hover:bg-white/80 transition-all">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Add some interactive animations
document.querySelectorAll('.card-hover').forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-4px) scale(1.01)';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0) scale(1)';
});
});
</script>
</body>
</html>

View File

@@ -1,529 +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 - Mobile-First Responsive</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&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.swipe-container {
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
.swipe-container::-webkit-scrollbar {
display: none;
}
.swipe-item {
scroll-snap-align: start;
flex-shrink: 0;
}
.mobile-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.bottom-nav {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.fab {
position: fixed;
bottom: 90px;
right: 20px;
z-index: 50;
}
@media (min-width: 768px) {
.fab {
bottom: 20px;
}
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>
</head>
<body class="bg-gray-50 pb-20 md:pb-0">
<!-- Mobile Header -->
<div class="gradient-bg px-4 pt-12 pb-6 md:pt-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl md:text-3xl font-bold text-white">Dashboard</h1>
<p class="text-white/80 text-sm">Bonjour Marie 👋</p>
</div>
<div class="flex items-center space-x-2">
<button class="p-2 bg-white/20 rounded-xl text-white">
<i data-lucide="search" class="w-5 h-5"></i>
</button>
<button class="p-2 bg-white/20 rounded-xl text-white relative">
<i data-lucide="bell" class="w-5 h-5"></i>
<span class="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full"></span>
</button>
</div>
</div>
<!-- Critical Alert -->
<div class="mobile-card rounded-2xl p-4 mb-6 border-l-4 border-red-400">
<div class="flex items-start space-x-3">
<div class="p-2 bg-red-100 rounded-lg">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-600"></i>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-900 text-sm">Action urgente</h3>
<p class="text-xs text-gray-600 mb-2">Billets expirent dans 25min</p>
<button class="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium w-full">
Payer maintenant - €70
</button>
</div>
</div>
</div>
<!-- Swipeable Metrics -->
<div class="swipe-container flex space-x-4 overflow-x-auto pb-2">
<div class="swipe-item mobile-card rounded-2xl p-4 min-w-[140px]">
<div class="flex items-center space-x-2 mb-2">
<i data-lucide="calendar-check" class="w-4 h-4 text-green-600"></i>
<span class="text-xs font-medium text-gray-600">Réservations</span>
</div>
<p class="text-2xl font-bold text-gray-900">5</p>
<p class="text-xs text-green-600">+2 ce mois</p>
</div>
<div class="swipe-item mobile-card rounded-2xl p-4 min-w-[140px]">
<div class="flex items-center space-x-2 mb-2">
<i data-lucide="clock" class="w-4 h-4 text-blue-600"></i>
<span class="text-xs font-medium text-gray-600">Aujourd'hui</span>
</div>
<p class="text-2xl font-bold text-gray-900">3</p>
<p class="text-xs text-blue-600">événements</p>
</div>
<div class="swipe-item mobile-card rounded-2xl p-4 min-w-[140px]">
<div class="flex items-center space-x-2 mb-2">
<i data-lucide="calendar" class="w-4 h-4 text-purple-600"></i>
<span class="text-xs font-medium text-gray-600">Demain</span>
</div>
<p class="text-2xl font-bold text-gray-900">7</p>
<p class="text-xs text-purple-600">événements</p>
</div>
<div class="swipe-item mobile-card rounded-2xl p-4 min-w-[140px]">
<div class="flex items-center space-x-2 mb-2">
<i data-lucide="trending-up" class="w-4 h-4 text-orange-600"></i>
<span class="text-xs font-medium text-gray-600">À venir</span>
</div>
<p class="text-2xl font-bold text-gray-900">15</p>
<p class="text-xs text-orange-600">cette semaine</p>
</div>
</div>
</div>
<!-- Main Content -->
<div class="px-4 -mt-4 relative z-10">
<!-- My Events Card -->
<div class="mobile-card rounded-2xl p-6 mb-6 slide-up">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Mes événements</h2>
<button class="text-blue-600 text-sm font-medium">Tout voir</button>
</div>
<!-- Mobile Event List -->
<div class="space-y-3">
<div class="flex items-center space-x-4 p-3 bg-gray-50 rounded-xl">
<div class="w-12 h-12 bg-gradient-to-r from-red-400 to-pink-600 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="music" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 truncate">Concert Rock Alternative</h3>
<p class="text-sm text-gray-600">Aujourd'hui 21:00</p>
</div>
<div class="text-right flex-shrink-0">
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full font-medium">Confirmé</span>
</div>
</div>
<div class="flex items-center space-x-4 p-3 bg-gray-50 rounded-xl">
<div class="w-12 h-12 bg-gradient-to-r from-blue-400 to-blue-600 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="users" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 truncate">Networking Tech</h3>
<p class="text-sm text-gray-600">Demain 19:00</p>
</div>
<div class="text-right flex-shrink-0">
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full font-medium">À venir</span>
</div>
</div>
<div class="flex items-center space-x-4 p-3 bg-gray-50 rounded-xl">
<div class="w-12 h-12 bg-gradient-to-r from-green-400 to-green-600 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="coffee" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 truncate">Brunch du Dimanche</h3>
<p class="text-sm text-gray-600">Dimanche 11:00</p>
</div>
<div class="text-right flex-shrink-0">
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full font-medium">En attente</span>
</div>
</div>
</div>
</div>
<!-- Today's Events -->
<div class="mobile-card rounded-2xl p-6 mb-6 slide-up">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Aujourd'hui</h2>
<button class="text-blue-600 text-sm font-medium">Planning</button>
</div>
<!-- Horizontal Scrollable Events -->
<div class="swipe-container flex space-x-4 overflow-x-auto">
<div class="swipe-item bg-white rounded-xl p-4 min-w-[280px] border">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-r from-purple-400 to-purple-600 rounded-lg flex items-center justify-center">
<i data-lucide="star" class="w-5 h-5 text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900">Festival de Cinéma</h3>
<p class="text-sm text-gray-600">20:30 • MK2</p>
</div>
</div>
<span class="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded-full">€25</span>
</div>
<button class="w-full bg-purple-600 text-white py-2 rounded-lg text-sm font-medium">
Réserver
</button>
</div>
<div class="swipe-item bg-white rounded-xl p-4 min-w-[280px] border">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-r from-yellow-400 to-orange-600 rounded-lg flex items-center justify-center">
<i data-lucide="utensils" class="w-5 h-5 text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900">Cours de Cuisine</h3>
<p class="text-sm text-gray-600">14:00 • École Ducasse</p>
</div>
</div>
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full">€85</span>
</div>
<button class="w-full bg-orange-600 text-white py-2 rounded-lg text-sm font-medium">
Réserver
</button>
</div>
<div class="swipe-item bg-white rounded-xl p-4 min-w-[280px] border">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-r from-teal-400 to-cyan-600 rounded-lg flex items-center justify-center">
<i data-lucide="camera" class="w-5 h-5 text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900">Exposition Photo</h3>
<p class="text-sm text-gray-600">10:00 • Galerie</p>
</div>
</div>
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">Gratuit</span>
</div>
<button class="w-full bg-teal-600 text-white py-2 rounded-lg text-sm font-medium">
Voir
</button>
</div>
</div>
</div>
<!-- Quick Actions Grid -->
<div class="grid grid-cols-2 gap-4 mb-6">
<button class="mobile-card rounded-2xl p-4 text-left slide-up hover:bg-white/60 transition-all">
<div class="flex items-center space-x-3">
<div class="p-3 bg-blue-100 rounded-xl">
<i data-lucide="search" class="w-6 h-6 text-blue-600"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 text-sm">Rechercher</h3>
<p class="text-xs text-gray-600">Événements</p>
</div>
</div>
</button>
<button class="mobile-card rounded-2xl p-4 text-left slide-up hover:bg-white/60 transition-all">
<div class="flex items-center space-x-3">
<div class="p-3 bg-green-100 rounded-xl">
<i data-lucide="heart" class="w-6 h-6 text-green-600"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 text-sm">Favoris</h3>
<p class="text-xs text-gray-600">12 événements</p>
</div>
</div>
</button>
</div>
<!-- Upcoming Events -->
<div class="mobile-card rounded-2xl p-6 slide-up">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Prochains événements</h2>
<button class="text-blue-600 text-sm font-medium">Tout voir</button>
</div>
<div class="space-y-4">
<!-- Event Item -->
<div class="flex items-start space-x-4 p-4 bg-gray-50 rounded-xl">
<div class="w-16 h-16 bg-gradient-to-r from-purple-400 to-purple-600 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="music" class="w-8 h-8 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold text-gray-900">Concert Électro</h3>
<span class="text-lg font-bold text-gray-900">€45</span>
</div>
<p class="text-sm text-gray-600 mb-2">Samedi 21 Sept • Berghain</p>
<div class="flex items-center justify-between">
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">12 places</span>
<button class="bg-purple-600 text-white px-4 py-1 rounded-lg text-sm font-medium">
Réserver
</button>
</div>
</div>
</div>
<div class="flex items-start space-x-4 p-4 bg-gray-50 rounded-xl">
<div class="w-16 h-16 bg-gradient-to-r from-green-400 to-teal-600 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="leaf" class="w-8 h-8 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold text-gray-900">Marché Bio</h3>
<span class="text-sm font-bold text-green-600">Gratuit</span>
</div>
<p class="text-sm text-gray-600 mb-2">Dimanche 22 Sept • Place des Vosges</p>
<div class="flex items-center justify-between">
<span class="text-xs bg-teal-100 text-teal-800 px-2 py-1 rounded-full">Accès libre</span>
<button class="bg-green-600 text-white px-4 py-1 rounded-lg text-sm font-medium">
Voir
</button>
</div>
</div>
</div>
<div class="flex items-start space-x-4 p-4 bg-gray-50 rounded-xl">
<div class="w-16 h-16 bg-gradient-to-r from-orange-400 to-red-600 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="book-open" class="w-8 h-8 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold text-gray-900">Salon du Livre</h3>
<span class="text-lg font-bold text-gray-900">€15</span>
</div>
<p class="text-sm text-gray-600 mb-2">Lundi 23 Sept • Grand Palais</p>
<div class="flex items-center justify-between">
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full">Populaire</span>
<button class="bg-orange-600 text-white px-4 py-1 rounded-lg text-sm font-medium">
Réserver
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Weekly Overview - Tablet and Desktop -->
<div class="hidden md:block mobile-card rounded-2xl p-6 mb-6 slide-up">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Vue hebdomadaire</h2>
<div class="grid grid-cols-7 gap-2">
<!-- Days of week -->
<div class="text-center p-3">
<p class="text-xs font-medium text-gray-500 mb-2">LUN</p>
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center mx-auto">
<span class="text-sm font-medium text-blue-600">18</span>
</div>
<div class="mt-2">
<div class="w-2 h-2 bg-blue-400 rounded-full mx-auto"></div>
</div>
</div>
<div class="text-center p-3">
<p class="text-xs font-medium text-gray-500 mb-2">MAR</p>
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center mx-auto">
<span class="text-sm font-medium text-green-600">19</span>
</div>
<div class="mt-2">
<div class="w-2 h-2 bg-green-400 rounded-full mx-auto"></div>
</div>
</div>
<div class="text-center p-3">
<p class="text-xs font-medium text-gray-500 mb-2">MER</p>
<div class="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center mx-auto">
<span class="text-sm font-medium text-gray-400">20</span>
</div>
</div>
<div class="text-center p-3">
<p class="text-xs font-medium text-gray-500 mb-2">JEU</p>
<div class="w-8 h-8 bg-purple-500 rounded-lg flex items-center justify-center mx-auto">
<span class="text-sm font-medium text-white">21</span>
</div>
<div class="mt-2 space-y-1">
<div class="w-2 h-2 bg-purple-400 rounded-full mx-auto"></div>
<div class="w-2 h-2 bg-purple-400 rounded-full mx-auto"></div>
</div>
</div>
<div class="text-center p-3">
<p class="text-xs font-medium text-gray-500 mb-2">VEN</p>
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center mx-auto">
<span class="text-sm font-medium text-yellow-600">22</span>
</div>
<div class="mt-2">
<div class="w-2 h-2 bg-yellow-400 rounded-full mx-auto"></div>
</div>
</div>
<div class="text-center p-3">
<p class="text-xs font-medium text-gray-500 mb-2">SAM</p>
<div class="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center mx-auto">
<span class="text-sm font-medium text-red-600">23</span>
</div>
<div class="mt-2 space-y-1">
<div class="w-2 h-2 bg-red-400 rounded-full mx-auto"></div>
<div class="w-2 h-2 bg-red-400 rounded-full mx-auto"></div>
<div class="w-2 h-2 bg-red-400 rounded-full mx-auto"></div>
</div>
</div>
<div class="text-center p-3">
<p class="text-xs font-medium text-gray-500 mb-2">DIM</p>
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center mx-auto">
<span class="text-sm font-medium text-orange-600">24</span>
</div>
<div class="mt-2">
<div class="w-2 h-2 bg-orange-400 rounded-full mx-auto"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Floating Action Button -->
<button class="fab w-14 h-14 bg-gradient-to-r from-purple-600 to-blue-600 rounded-full shadow-lg flex items-center justify-center text-white hover:scale-110 transition-transform">
<i data-lucide="plus" class="w-6 h-6"></i>
</button>
<!-- Mobile Bottom Navigation -->
<div class="md:hidden fixed bottom-0 left-0 right-0 bottom-nav px-4 py-3">
<div class="flex items-center justify-around">
<button class="flex flex-col items-center space-y-1 text-blue-600">
<i data-lucide="home" class="w-5 h-5"></i>
<span class="text-xs font-medium">Accueil</span>
</button>
<button class="flex flex-col items-center space-y-1 text-gray-500">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span class="text-xs">Événements</span>
</button>
<button class="flex flex-col items-center space-y-1 text-gray-500">
<i data-lucide="ticket" class="w-5 h-5"></i>
<span class="text-xs">Billets</span>
</button>
<button class="flex flex-col items-center space-y-1 text-gray-500 relative">
<i data-lucide="bell" class="w-5 h-5"></i>
<span class="text-xs">Alertes</span>
<span class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<button class="flex flex-col items-center space-y-1 text-gray-500">
<i data-lucide="user" class="w-5 h-5"></i>
<span class="text-xs">Profil</span>
</button>
</div>
</div>
<!-- Desktop Sidebar Navigation (shown on larger screens) -->
<div class="hidden lg:block fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-40">
<div class="p-6">
<h1 class="text-xl font-bold text-gray-900 mb-8">ApéroNight</h1>
<nav class="space-y-2">
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg bg-blue-50 text-blue-700">
<i data-lucide="home" class="w-5 h-5"></i>
<span class="font-medium">Dashboard</span>
</a>
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-50">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>Événements</span>
</a>
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-50">
<i data-lucide="ticket" class="w-5 h-5"></i>
<span>Mes billets</span>
</a>
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-50">
<i data-lucide="bar-chart-3" class="w-5 h-5"></i>
<span>Analytics</span>
</a>
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-50">
<i data-lucide="settings" class="w-5 h-5"></i>
<span>Paramètres</span>
</a>
</nav>
</div>
</div>
<!-- Adjust content margin for desktop sidebar -->
<style>
@media (min-width: 1024px) {
body {
margin-left: 256px;
}
}
</style>
<script>
lucide.createIcons();
// Add touch interactions for mobile
let startX, scrollLeft;
const sliders = document.querySelectorAll('.swipe-container');
sliders.forEach(slider => {
slider.addEventListener('touchstart', e => {
startX = e.touches[0].pageX - slider.offsetLeft;
scrollLeft = slider.scrollLeft;
});
slider.addEventListener('touchmove', e => {
const x = e.touches[0].pageX - slider.offsetLeft;
const walk = (x - startX) * 2;
slider.scrollLeft = scrollLeft - walk;
});
});
// Stagger animations
const slideElements = document.querySelectorAll('.slide-up');
slideElements.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 - Neo-Brutalism Style</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=Space+Mono:wght@400;700&family=DM+Sans:wght@400;500;700;900&display=swap" rel="stylesheet">
<style>
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--secondary: oklch(0.9680 0.2110 109.7692);
--accent: oklch(0.5635 0.2408 260.8178);
--muted: oklch(0.9551 0 0);
--border: oklch(0 0 0);
}
body {
font-family: 'DM Sans', sans-serif;
background-color: var(--background);
color: var(--foreground);
}
.mono { font-family: 'Space Mono', monospace; }
.brutal-card {
background: var(--background);
border: 4px solid var(--border);
box-shadow: 8px 8px 0px 0px var(--border);
transition: all 0.1s ease;
}
.brutal-card:hover {
transform: translate(-2px, -2px);
box-shadow: 10px 10px 0px 0px var(--border);
}
.brutal-btn {
border: 3px solid var(--border);
background: var(--primary);
color: white;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 4px 4px 0px 0px var(--border);
transition: all 0.1s ease;
}
.brutal-btn:hover {
transform: translate(-1px, -1px);
box-shadow: 6px 6px 0px 0px var(--border);
}
.brutal-btn:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0px 0px var(--border);
}
.brutal-secondary {
background: var(--secondary);
color: var(--border);
}
.brutal-accent {
background: var(--accent);
color: white;
}
.brutal-input {
border: 3px solid var(--border);
background: var(--background);
padding: 12px 16px;
font-family: 'Space Mono', monospace;
font-weight: 700;
}
.brutal-input:focus {
outline: none;
box-shadow: 4px 4px 0px 0px var(--border);
}
.zigzag-border {
background-image: linear-gradient(45deg, var(--border) 25%, transparent 25%),
linear-gradient(-45deg, var(--border) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--border) 75%),
linear-gradient(-45deg, transparent 75%, var(--border) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
.glitch {
position: relative;
color: var(--primary);
font-weight: 900;
}
.glitch:before {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: var(--accent);
z-index: -1;
animation: glitch1 0.5s infinite;
}
.glitch:after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: var(--secondary);
z-index: -2;
animation: glitch2 0.5s infinite;
}
@keyframes glitch1 {
0%, 100% { transform: translate(0); }
20% { transform: translate(-1px, 1px); }
40% { transform: translate(-1px, -1px); }
60% { transform: translate(1px, 1px); }
80% { transform: translate(1px, -1px); }
}
@keyframes glitch2 {
0%, 100% { transform: translate(0); }
20% { transform: translate(1px, -1px); }
40% { transform: translate(1px, 1px); }
60% { transform: translate(-1px, -1px); }
80% { transform: translate(-1px, 1px); }
}
</style>
</head>
<body class="min-h-screen" style="background: var(--muted);">
<!-- Header -->
<div class="relative p-6 mb-8" style="background: var(--primary);">
<div class="absolute inset-0 zigzag-border opacity-10"></div>
<div class="relative z-10 max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="glitch text-4xl md:text-5xl font-black text-white mono" data-text="DASHBOARD">DASHBOARD</h1>
<p class="text-white/80 font-bold uppercase tracking-wider text-sm">USER CONTROL PANEL v2.1</p>
</div>
<div class="flex items-center space-x-4">
<button class="brutal-btn px-4 py-2 text-sm">SETTINGS</button>
<button class="brutal-btn brutal-secondary px-4 py-2 text-sm">HELP</button>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-6">
<!-- CRITICAL ALERT -->
<div class="brutal-card p-6 mb-8" style="background: #ff4444; border-color: #000; color: white;">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-4">
<div class="p-3 bg-white rounded-none border-2 border-black">
<i data-lucide="zap" class="w-6 h-6 text-black"></i>
</div>
<div>
<h3 class="font-black text-lg mono uppercase">PAYMENT ALERT!</h3>
<p class="text-white/90 text-sm font-bold mb-3">2 TICKETS EXPIRE IN 25 MINUTES</p>
<div class="bg-white p-3 border-2 border-black text-black">
<div class="flex items-center justify-between">
<div>
<span class="mono font-bold text-sm">SOIRÉE JAZZ AU SUNSET</span>
<p class="text-xs">2 TICKETS • ATTEMPT 1/3</p>
</div>
<span class="mono font-black text-lg">€70</span>
</div>
</div>
</div>
</div>
<button class="brutal-btn px-6 py-3 font-black" style="background: white; color: black; border-color: black;">
PAY NOW!
</button>
</div>
</div>
<!-- Metrics Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
<div class="brutal-card p-6" style="background: var(--secondary);">
<div class="flex items-center justify-between mb-4">
<i data-lucide="calendar-check" class="w-8 h-8" style="color: var(--border);"></i>
<span class="mono text-xs font-bold" style="color: var(--border);">BOOKINGS</span>
</div>
<div class="mono text-4xl font-black mb-2" style="color: var(--border);">05</div>
<div class="mono text-xs font-bold" style="color: var(--border);">+2 THIS MONTH</div>
</div>
<div class="brutal-card p-6" style="background: var(--accent);">
<div class="flex items-center justify-between mb-4">
<i data-lucide="clock" class="w-8 h-8 text-white"></i>
<span class="mono text-xs font-bold text-white">TODAY</span>
</div>
<div class="mono text-4xl font-black text-white mb-2">03</div>
<div class="mono text-xs font-bold text-white">EVENTS</div>
</div>
<div class="brutal-card p-6" style="background: #ff6b6b;">
<div class="flex items-center justify-between mb-4">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
<span class="mono text-xs font-bold text-white">TOMORROW</span>
</div>
<div class="mono text-4xl font-black text-white mb-2">07</div>
<div class="mono text-xs font-bold text-white">EVENTS</div>
</div>
<div class="brutal-card p-6" style="background: #4ecdc4;">
<div class="flex items-center justify-between mb-4">
<i data-lucide="trending-up" class="w-8 h-8 text-white"></i>
<span class="mono text-xs font-bold text-white">UPCOMING</span>
</div>
<div class="mono text-4xl font-black text-white mb-2">15</div>
<div class="mono text-xs font-bold text-white">THIS WEEK</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- My Events -->
<div class="brutal-card p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="mono text-xl font-black uppercase">MY EVENTS</h2>
<button class="mono text-sm font-bold underline" style="color: var(--accent);">VIEW ALL</button>
</div>
<div class="space-y-4">
<div class="border-4 border-black p-4" style="background: var(--secondary);">
<div class="flex items-start justify-between mb-2">
<div>
<h3 class="mono font-black text-sm uppercase">CONCERT ROCK ALTERNATIVE</h3>
<p class="text-xs font-bold">TODAY 21:00 • SALLE PLEYEL</p>
</div>
<span class="mono text-xs font-black bg-green-400 text-black px-2 py-1 border-2 border-black">CONFIRMED</span>
</div>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-1">
<i data-lucide="users" class="w-3 h-3"></i>
<span class="mono text-xs font-bold">156</span>
</div>
<div class="flex items-center space-x-1">
<i data-lucide="star" class="w-3 h-3 fill-current text-yellow-500"></i>
<span class="mono text-xs font-bold">4.7</span>
</div>
</div>
</div>
<div class="border-4 border-black p-4" style="background: var(--accent);">
<div class="flex items-start justify-between mb-2">
<div>
<h3 class="mono font-black text-sm uppercase text-white">NETWORKING TECH</h3>
<p class="text-xs font-bold text-white/90">TOMORROW 19:00 • WEWORK</p>
</div>
<span class="mono text-xs font-black bg-blue-400 text-black px-2 py-1 border-2 border-black">TOMORROW</span>
</div>
<div class="w-full bg-white/20 h-2 border border-white">
<div class="bg-white h-full" style="width: 84%"></div>
</div>
<span class="mono text-xs font-bold text-white mt-1">42/50 PARTICIPANTS</span>
</div>
<div class="border-4 border-black p-4" style="background: #ff6b6b;">
<div class="flex items-start justify-between mb-2">
<div>
<h3 class="mono font-black text-sm uppercase text-white">BRUNCH DU DIMANCHE</h3>
<p class="text-xs font-bold text-white/90">SUNDAY 11:00 • CAFÉ DE FLORE</p>
</div>
<span class="mono text-xs font-black bg-yellow-400 text-black px-2 py-1 border-2 border-black">PENDING</span>
</div>
</div>
</div>
</div>
<!-- Today's Events -->
<div class="brutal-card p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="mono text-xl font-black uppercase">TODAY'S EVENTS</h2>
<button class="mono text-sm font-bold underline" style="color: var(--accent);">VIEW ALL</button>
</div>
<div class="space-y-4">
<div class="border-4 border-black p-4 bg-white relative overflow-hidden">
<div class="absolute top-0 right-0 w-16 h-16 transform rotate-45 translate-x-8 -translate-y-8" style="background: var(--primary);"></div>
<div class="relative z-10">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 border-3 border-black flex items-center justify-center" style="background: #ff4757;">
<i data-lucide="star" class="w-6 h-6 text-white"></i>
</div>
<div>
<h3 class="mono font-black text-sm uppercase">FESTIVAL CINÉMA</h3>
<p class="text-xs font-bold">20:30 • MK2 BIBLIO</p>
</div>
</div>
<span class="mono font-black text-lg">€25</span>
</div>
<button class="brutal-btn w-full py-2 text-sm">BOOK NOW!</button>
</div>
</div>
<div class="border-4 border-black p-4 bg-white relative overflow-hidden">
<div class="absolute top-0 left-0 w-0 h-0 border-t-16 border-r-16" style="border-top-color: var(--secondary); border-right-color: transparent;"></div>
<div class="relative z-10">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 border-3 border-black flex items-center justify-center" style="background: #ffa502;">
<i data-lucide="utensils" class="w-6 h-6 text-white"></i>
</div>
<div>
<h3 class="mono font-black text-sm uppercase">COOKING CLASS</h3>
<p class="text-xs font-bold">14:00 • ÉCOLE DUCASSE</p>
</div>
</div>
<span class="mono font-black text-lg">€85</span>
</div>
<button class="brutal-btn brutal-secondary w-full py-2 text-sm">BOOK NOW!</button>
</div>
</div>
<div class="border-4 border-black p-4 bg-white relative overflow-hidden">
<div class="absolute bottom-0 right-0 w-0 h-0 border-b-16 border-l-16" style="border-bottom-color: var(--accent); border-left-color: transparent;"></div>
<div class="relative z-10">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 border-3 border-black flex items-center justify-center" style="background: #2ed573;">
<i data-lucide="camera" class="w-6 h-6 text-white"></i>
</div>
<div>
<h3 class="mono font-black text-sm uppercase">PHOTO EXPO</h3>
<p class="text-xs font-bold">10:00 • GALERIE PERROTIN</p>
</div>
</div>
<span class="mono font-black text-sm" style="color: var(--accent);">FREE</span>
</div>
<button class="brutal-btn brutal-accent w-full py-2 text-sm">VIEW DETAILS</button>
</div>
</div>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="mt-8 brutal-card p-6">
<h2 class="mono text-xl font-black uppercase mb-6">USER STATISTICS</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Performance Chart -->
<div class="md:col-span-2">
<h3 class="mono font-black text-sm uppercase mb-4">PERFORMANCE METRICS</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="mono font-bold text-sm">SUCCESS RATE</span>
<div class="flex items-center space-x-3">
<div class="w-32 h-4 border-2 border-black bg-white">
<div class="h-full border-r-2 border-black" style="width: 94%; background: var(--secondary);"></div>
</div>
<span class="mono font-black">94%</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="mono font-bold text-sm">ENGAGEMENT</span>
<div class="flex items-center space-x-3">
<div class="w-32 h-4 border-2 border-black bg-white">
<div class="h-full border-r-2 border-black" style="width: 78%; background: var(--accent);"></div>
</div>
<span class="mono font-black">78%</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="mono font-bold text-sm">SATISFACTION</span>
<div class="flex items-center space-x-3">
<div class="w-32 h-4 border-2 border-black bg-white">
<div class="h-full border-r-2 border-black" style="width: 89%; background: var(--primary);"></div>
</div>
<span class="mono font-black">89%</span>
</div>
</div>
</div>
</div>
<!-- Quick Stats -->
<div>
<h3 class="mono font-black text-sm uppercase mb-4">QUICK STATS</h3>
<div class="space-y-3">
<div class="border-2 border-black p-3 bg-white">
<div class="flex items-center justify-between">
<span class="mono text-xs font-bold">TOTAL EVENTS</span>
<span class="mono font-black">127</span>
</div>
</div>
<div class="border-2 border-black p-3" style="background: var(--secondary);">
<div class="flex items-center justify-between">
<span class="mono text-xs font-bold">PARTICIPANTS</span>
<span class="mono font-black">2,456</span>
</div>
</div>
<div class="border-2 border-black p-3" style="background: var(--accent);">
<div class="flex items-center justify-between">
<span class="mono text-xs font-bold text-white">RATING</span>
<span class="mono font-black text-white">4.8/5</span>
</div>
</div>
<div class="border-2 border-black p-3" style="background: var(--primary);">
<div class="flex items-center justify-between">
<span class="mono text-xs font-bold text-white">REVENUE</span>
<span class="mono font-black text-white">€12,340</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Upcoming Events Grid -->
<div class="brutal-card p-6 mb-8">
<div class="flex items-center justify-between mb-6">
<h2 class="mono text-xl font-black uppercase">UPCOMING EVENTS</h2>
<div class="flex items-center space-x-2">
<button class="mono text-xs font-bold px-3 py-1 border-2 border-black bg-white hover:bg-gray-100">FILTER</button>
<button class="mono text-xs font-bold px-3 py-1 border-2 border-black bg-white hover:bg-gray-100">SORT</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Event Card 1 -->
<div class="border-4 border-black bg-white relative overflow-hidden">
<div class="h-32 flex items-center justify-center relative" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);">
<i data-lucide="music" class="w-12 h-12 text-white"></i>
<div class="absolute top-2 right-2 mono text-xs font-black bg-white text-black px-2 py-1 border border-black">HOT!</div>
</div>
<div class="p-4">
<h3 class="mono font-black text-sm uppercase mb-2">CONCERT ÉLECTRO</h3>
<p class="text-xs font-bold mb-3">SAT 21 SEPT • BERGHAIN</p>
<div class="flex items-center justify-between">
<span class="mono font-black text-lg">€45</span>
<button class="brutal-btn px-4 py-1 text-xs">BOOK!</button>
</div>
</div>
</div>
<!-- Event Card 2 -->
<div class="border-4 border-black bg-white relative overflow-hidden">
<div class="h-32 flex items-center justify-center relative" style="background: linear-gradient(45deg, #2ed573, #1e90ff);">
<i data-lucide="leaf" class="w-12 h-12 text-white"></i>
<div class="absolute top-2 right-2 mono text-xs font-black bg-green-400 text-black px-2 py-1 border border-black">ECO</div>
</div>
<div class="p-4">
<h3 class="mono font-black text-sm uppercase mb-2">MARCHÉ BIO</h3>
<p class="text-xs font-bold mb-3">SUN 22 SEPT • PLACE VOSGES</p>
<div class="flex items-center justify-between">
<span class="mono font-black text-lg" style="color: var(--accent);">FREE</span>
<button class="brutal-btn brutal-secondary px-4 py-1 text-xs">VIEW</button>
</div>
</div>
</div>
<!-- Event Card 3 -->
<div class="border-4 border-black bg-white relative overflow-hidden">
<div class="h-32 flex items-center justify-center relative" style="background: linear-gradient(45deg, #a55eea, #ff6b6b);">
<i data-lucide="book-open" class="w-12 h-12 text-white"></i>
<div class="absolute top-2 right-2 mono text-xs font-black bg-purple-400 text-black px-2 py-1 border border-black">NEW</div>
</div>
<div class="p-4">
<h3 class="mono font-black text-sm uppercase mb-2">SALON DU LIVRE</h3>
<p class="text-xs font-bold mb-3">MON 23 SEPT • GRAND PALAIS</p>
<div class="flex items-center justify-between">
<span class="mono font-black text-lg">€15</span>
<button class="brutal-btn brutal-accent px-4 py-1 text-xs">BOOK!</button>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<button class="brutal-btn p-6 text-center">
<i data-lucide="plus" class="w-8 h-8 mx-auto mb-2"></i>
<span class="mono font-black text-sm">CREATE EVENT</span>
</button>
<button class="brutal-btn brutal-secondary p-6 text-center">
<i data-lucide="search" class="w-8 h-8 mx-auto mb-2"></i>
<span class="mono font-black text-sm">FIND EVENTS</span>
</button>
<button class="brutal-btn brutal-accent p-6 text-center">
<i data-lucide="heart" class="w-8 h-8 mx-auto mb-2"></i>
<span class="mono font-black text-sm">MY FAVORITES</span>
</button>
</div>
</div>
<!-- Floating Action Button -->
<button class="fixed bottom-6 right-6 w-16 h-16 border-4 border-black flex items-center justify-center font-black text-white hover:scale-110 transition-transform z-50" style="background: var(--primary); box-shadow: 6px 6px 0px 0px var(--border);">
<i data-lucide="zap" class="w-8 h-8"></i>
</button>
<script>
lucide.createIcons();
// Add some brutal interactions
document.querySelectorAll('.brutal-card').forEach(card => {
card.addEventListener('click', function() {
this.style.transform = 'translate(2px, 2px)';
this.style.boxShadow = '4px 4px 0px 0px var(--border)';
setTimeout(() => {
this.style.transform = '';
this.style.boxShadow = '';
}, 100);
});
});
// Brutal shake animation for alerts
const alertCard = document.querySelector('.brutal-card');
setInterval(() => {
if (alertCard && alertCard.style.background === 'rgb(255, 68, 68)') {
alertCard.style.animation = 'shake 0.3s ease-in-out';
setTimeout(() => {
alertCard.style.animation = '';
}, 300);
}
}, 3000);
// Define shake animation
const style = document.createElement('style');
style.textContent = `
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
`;
document.head.appendChild(style);
</script>
</body>
</html>

View File

@@ -1,642 +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 - Hybrid Minimalist + Data Visualization</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&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); }
.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>
<div class="min-h-screen">
<!-- Navigation (from dashboard_2) -->
<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 (from dashboard_2) -->
<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 (from dashboard_2 style) -->
<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 (from dashboard_2) -->
<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>
<!-- KPI Cards with Progress (from dashboard_3) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Participation Rate -->
<div class="stat-card rounded-2xl p-6 minimal-card fade-in">
<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 minimal-card fade-in">
<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 minimal-card fade-in">
<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 minimal-card fade-in">
<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>
<!-- Charts and Analytics (from dashboard_3) -->
<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 minimal-card">
<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 minimal-card">
<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 (from dashboard_3) -->
<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 minimal-card">
<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 (from dashboard_3) -->
<div class="space-y-6">
<!-- Performance Metrics -->
<div class="bg-white rounded-2xl p-6 shadow-sm minimal-card">
<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 minimal-card">
<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 minimal-card">
<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="mono font-medium">127</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Participants totaux</span>
<span class="mono 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="mono 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="mono 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
}
}
}
});
// 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,618 +0,0 @@
/* Enhanced Aperonight Design System - Event Booking Optimized */
:root {
/* Enhanced Purple gradient system with more depth */
--color-primary-50: #faf5ff;
--color-primary-100: #f3e8ff;
--color-primary-200: #e9d5ff;
--color-primary-300: #d8b4fe;
--color-primary-400: #c084fc;
--color-primary-500: #a855f7;
--color-primary-600: #9333ea;
--color-primary-700: #7e22ce;
--color-primary-800: #6b21a8;
--color-primary-900: #581c87;
--color-primary-950: #3b0764; /* Added for deeper contrast */
/* Enhanced Pink gradient for event highlights */
--color-accent-300: #f9a8d4;
--color-accent-400: #f472b6;
--color-accent-500: #ec4899;
--color-accent-600: #db2777;
--color-accent-700: #be185d; /* Added for better hierarchy */
/* Enhanced Neutral system with warmer tones */
--color-neutral-50: #f8fafc;
--color-neutral-100: #f1f5f9;
--color-neutral-200: #e2e8f0;
--color-neutral-300: #cbd5e1;
--color-neutral-400: #94a3b8;
--color-neutral-500: #64748b;
--color-neutral-600: #475569;
--color-neutral-700: #334155;
--color-neutral-800: #1e293b;
--color-neutral-900: #0f172a;
--color-neutral-950: #020617; /* Added for deeper backgrounds */
/* Event-specific semantic colors */
--color-success-light: #dcfce7;
--color-success: #16a34a;
--color-success-dark: #15803d;
--color-warning-light: #fef3c7;
--color-warning: #f59e0b;
--color-warning-dark: #d97706;
--color-danger-light: #fee2e2;
--color-danger: #dc2626;
--color-danger-dark: #b91c1c;
/* Event status colors */
--color-event-featured: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
--color-event-available: var(--color-success);
--color-event-limited: var(--color-warning);
--color-event-sold-out: var(--color-danger);
--color-event-vip: linear-gradient(135deg, #ffd700 0%, #ffb347 100%);
/* Enhanced Typography with better hierarchy */
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-display: 'Outfit', var(--font-sans); /* For headings and key content */
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', monospace;
/* Enhanced font sizes with golden ratio scaling */
--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 - for hero sections */
--text-6xl: 3.75rem; /* 60px - for major headings */
/* Enhanced spacing system */
--space-px: 1px;
--space-0-5: 0.125rem; /* 2px */
--space-1: 0.25rem; /* 4px */
--space-1-5: 0.375rem; /* 6px */
--space-2: 0.5rem; /* 8px */
--space-2-5: 0.625rem; /* 10px */
--space-3: 0.75rem; /* 12px */
--space-3-5: 0.875rem; /* 14px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-7: 1.75rem; /* 28px */
--space-8: 2rem; /* 32px */
--space-9: 2.25rem; /* 36px */
--space-10: 2.5rem; /* 40px */
--space-11: 2.75rem; /* 44px */
--space-12: 3rem; /* 48px */
--space-14: 3.5rem; /* 56px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
--space-24: 6rem; /* 96px */
/* Enhanced border radius system */
--radius-none: 0px;
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.375rem; /* 6px */
--radius: 0.5rem; /* 8px */
--radius-lg: 0.75rem; /* 12px */
--radius-xl: 1rem; /* 16px */
--radius-2xl: 1.5rem; /* 24px */
--radius-3xl: 2rem; /* 32px */
--radius-full: 9999px;
/* Enhanced shadow system with color variations */
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--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);
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
/* Purple-tinted shadows for premium feel */
--shadow-purple-sm: 0 1px 3px 0 rgb(147 51 234 / 0.1), 0 1px 2px -1px rgb(147 51 234 / 0.1);
--shadow-purple-md: 0 4px 6px -1px rgb(147 51 234 / 0.1), 0 2px 4px -2px rgb(147 51 234 / 0.1);
--shadow-purple-lg: 0 10px 15px -3px rgb(147 51 234 / 0.15), 0 4px 6px -4px rgb(147 51 234 / 0.1);
/* Pink-tinted shadows for event highlights */
--shadow-pink-sm: 0 1px 3px 0 rgb(236 72 153 / 0.1), 0 1px 2px -1px rgb(236 72 153 / 0.1);
--shadow-pink-md: 0 4px 6px -1px rgb(236 72 153 / 0.1), 0 2px 4px -2px rgb(236 72 153 / 0.1);
/* Animation durations */
--duration-fast: 0.15s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
--duration-slower: 0.5s;
/* Easing functions */
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* Enhanced Component Styles */
/* Buttons with improved hierarchy */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
font-family: var(--font-display);
font-weight: 600;
white-space: nowrap;
transition: all var(--duration-normal) var(--ease-in-out);
cursor: pointer;
border: none;
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.btn-sm {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
border-radius: var(--radius-md);
}
.btn-md {
padding: var(--space-3) var(--space-4);
font-size: var(--text-base);
border-radius: var(--radius);
}
.btn-lg {
padding: var(--space-4) var(--space-6);
font-size: var(--text-lg);
border-radius: var(--radius-lg);
}
.btn-primary {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
color: white;
box-shadow: var(--shadow-purple-sm);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--color-accent-600) 100%);
box-shadow: var(--shadow-purple-md);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
box-shadow: var(--shadow-purple-sm);
}
.btn-secondary {
background: white;
color: var(--color-primary-600);
border: 1px solid var(--color-neutral-200);
box-shadow: var(--shadow-sm);
}
.btn-secondary:hover {
background: var(--color-primary-50);
border-color: var(--color-primary-300);
box-shadow: var(--shadow-md);
}
.btn-outline {
background: transparent;
color: var(--color-primary-600);
border: 2px solid var(--color-primary-600);
}
.btn-outline:hover {
background: var(--color-primary-600);
color: white;
box-shadow: var(--shadow-purple-sm);
}
.btn-ghost {
background: transparent;
color: var(--color-neutral-600);
border: none;
}
.btn-ghost:hover {
background: var(--color-neutral-100);
color: var(--color-primary-600);
}
/* Enhanced Cards */
.card {
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-neutral-200);
overflow: hidden;
transition: all var(--duration-normal) var(--ease-in-out);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-interactive {
cursor: pointer;
}
.card-interactive:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-4px);
border-color: var(--color-primary-200);
}
.card-header {
padding: var(--space-6);
border-bottom: 1px solid var(--color-neutral-200);
}
.card-body {
padding: var(--space-6);
}
.card-footer {
padding: var(--space-4) var(--space-6);
background: var(--color-neutral-50);
border-top: 1px solid var(--color-neutral-200);
}
/* Event-specific cards */
.event-card {
position: relative;
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
overflow: hidden;
transition: all var(--duration-slow) var(--ease-out);
border: 1px solid var(--color-neutral-200);
}
.event-card:hover {
box-shadow: var(--shadow-2xl);
transform: translateY(-6px) scale(1.02);
}
.event-card-featured {
border: 2px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, var(--color-primary-600), var(--color-accent-500)) border-box;
box-shadow: var(--shadow-purple-lg);
}
.event-card-featured::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--color-event-featured);
}
.event-card-image {
aspect-ratio: 16/9;
overflow: hidden;
position: relative;
}
.event-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--duration-slow) var(--ease-out);
}
.event-card:hover .event-card-image img {
transform: scale(1.05);
}
/* Enhanced Forms */
.form-group {
margin-bottom: var(--space-6);
}
.form-label {
display: block;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-neutral-700);
margin-bottom: var(--space-2);
font-family: var(--font-display);
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: var(--space-3) var(--space-4);
font-size: var(--text-base);
color: var(--color-neutral-900);
background: white;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-lg);
transition: all var(--duration-normal) var(--ease-in-out);
font-family: var(--font-sans);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgb(168 85 247 / 0.1);
background: var(--color-primary-50);
}
.form-error {
color: var(--color-danger);
font-size: var(--text-sm);
margin-top: var(--space-1);
display: flex;
align-items: center;
gap: var(--space-1);
}
/* Enhanced Badges */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1-5) var(--space-3);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: var(--radius-full);
font-family: var(--font-display);
}
.badge-available {
background: var(--color-success-light);
color: var(--color-success-dark);
}
.badge-limited {
background: var(--color-warning-light);
color: var(--color-warning-dark);
animation: pulse 2s infinite;
}
.badge-sold-out {
background: var(--color-danger-light);
color: var(--color-danger-dark);
}
.badge-featured {
background: linear-gradient(135deg, var(--color-primary-100), var(--color-accent-100));
color: var(--color-primary-800);
border: 1px solid var(--color-primary-200);
}
.badge-vip {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #92400e;
border: 1px solid #fbbf24;
box-shadow: var(--shadow-xs);
}
/* Enhanced Navigation */
.nav {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--color-neutral-200);
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 50;
}
.nav-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-weight: 500;
color: var(--color-neutral-600);
text-decoration: none;
border-radius: var(--radius-lg);
transition: all var(--duration-normal) var(--ease-in-out);
font-family: var(--font-display);
}
.nav-link:hover,
.nav-link.active {
color: var(--color-primary-600);
background: var(--color-primary-50);
box-shadow: var(--shadow-sm);
}
/* Enhanced Layout */
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--space-4);
}
@media (min-width: 640px) {
.container { padding: 0 var(--space-6); }
}
@media (min-width: 1024px) {
.container { padding: 0 var(--space-8); }
}
.grid-responsive {
display: grid;
gap: var(--space-6);
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.grid-responsive { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.grid-responsive { grid-template-columns: repeat(3, 1fr); }
}
.grid-events {
display: grid;
gap: var(--space-8);
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.grid-events { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.grid-events { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1280px) {
.grid-events { grid-template-columns: repeat(4, 1fr); }
}
/* Enhanced animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-fadeInUp {
animation: fadeInUp 0.6s var(--ease-out);
}
.animate-slideInRight {
animation: slideInRight 0.4s var(--ease-out);
}
.animate-pulse {
animation: pulse 2s infinite;
}
.loading-shimmer {
background: linear-gradient(90deg, var(--color-neutral-100) 25%, var(--color-neutral-200) 50%, var(--color-neutral-100) 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
/* Enhanced hover effects */
.hover-lift {
transition: transform var(--duration-normal) var(--ease-out);
}
.hover-lift:hover {
transform: translateY(-2px);
}
.hover-glow {
transition: all var(--duration-normal) var(--ease-out);
}
.hover-glow:hover {
box-shadow: var(--shadow-purple-lg);
filter: brightness(1.05);
}
/* Focus states with better accessibility */
.focus-ring {
transition: all var(--duration-fast) var(--ease-out);
}
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 3px rgb(168 85 247 / 0.2);
}
/* Dark mode enhancements */
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--color-neutral-900);
--color-surface: var(--color-neutral-800);
--color-border: var(--color-neutral-700);
--color-text-primary: var(--color-neutral-50);
--color-text-secondary: var(--color-neutral-300);
--color-text-muted: var(--color-neutral-500);
}
.card {
background: var(--color-neutral-800);
border-color: var(--color-neutral-700);
}
.form-input,
.form-select,
.form-textarea {
background: var(--color-neutral-700);
border-color: var(--color-neutral-600);
color: var(--color-neutral-100);
}
.nav {
background: rgba(30, 41, 59, 0.95);
border-bottom-color: var(--color-neutral-700);
}
}
/* Print styles */
@media print {
.btn,
.nav,
.card:hover {
box-shadow: none !important;
transform: none !important;
}
.event-card {
break-inside: avoid;
margin-bottom: var(--space-4);
}
}

View File

@@ -0,0 +1,53 @@
:root {
--background: oklch(0.9961 0.0039 106.7952);
--foreground: oklch(0.0902 0.0203 286.0532);
--card: oklch(0.9961 0.0039 106.7952);
--card-foreground: oklch(0.0902 0.0203 286.0532);
--popover: oklch(0.9961 0.0039 106.7952);
--popover-foreground: oklch(0.0902 0.0203 286.0532);
--primary: oklch(0.4902 0.2314 320.7094);
--primary-foreground: oklch(0.9961 0.0039 106.7952);
--secondary: oklch(0.6471 0.1686 342.5570);
--secondary-foreground: oklch(0.0902 0.0203 286.0532);
--muted: oklch(0.9412 0.0196 106.7952);
--muted-foreground: oklch(0.4706 0.0157 286.0532);
--accent: oklch(0.7255 0.1451 51.2345);
--accent-foreground: oklch(0.0902 0.0203 286.0532);
--destructive: oklch(0.5765 0.2314 27.3319);
--destructive-foreground: oklch(0.9961 0.0039 106.7952);
--border: oklch(0.8824 0.0157 106.7952);
--input: oklch(0.8824 0.0157 106.7952);
--ring: oklch(0.4902 0.2314 320.7094);
--chart-1: oklch(0.4902 0.2314 320.7094);
--chart-2: oklch(0.6471 0.1686 342.5570);
--chart-3: oklch(0.7255 0.1451 51.2345);
--chart-4: oklch(0.5490 0.2157 142.4953);
--chart-5: oklch(0.6157 0.2275 328.3634);
--sidebar: oklch(0.9412 0.0196 106.7952);
--sidebar-foreground: oklch(0.0902 0.0203 286.0532);
--sidebar-primary: oklch(0.4902 0.2314 320.7094);
--sidebar-primary-foreground: oklch(0.9961 0.0039 106.7952);
--sidebar-accent: oklch(0.6471 0.1686 342.5570);
--sidebar-accent-foreground: oklch(0.0902 0.0203 286.0532);
--sidebar-border: oklch(0.8824 0.0157 106.7952);
--sidebar-ring: oklch(0.4902 0.2314 320.7094);
--font-sans: 'Inter', sans-serif;
--font-serif: 'Playfair Display', serif;
--font-mono: 'Fira Code', monospace;
--radius: 1rem;
--shadow-2xs: 0 1px 2px 0px hsl(320 70% 20% / 0.08);
--shadow-xs: 0 1px 3px 0px hsl(320 70% 20% / 0.10);
--shadow-sm: 0 2px 4px 0px hsl(320 70% 20% / 0.10), 0 1px 2px -1px hsl(320 70% 20% / 0.06);
--shadow: 0 4px 6px 0px hsl(320 70% 20% / 0.12), 0 2px 4px -1px hsl(320 70% 20% / 0.08);
--shadow-md: 0 6px 8px 0px hsl(320 70% 20% / 0.15), 0 4px 6px -1px hsl(320 70% 20% / 0.10);
--shadow-lg: 0 10px 15px 0px hsl(320 70% 20% / 0.20), 0 6px 8px -1px hsl(320 70% 20% / 0.15);
--shadow-xl: 0 20px 25px 0px hsl(320 70% 20% / 0.25), 0 10px 15px -1px hsl(320 70% 20% / 0.20);
--shadow-2xl: 0 25px 50px 0px hsl(320 70% 20% / 0.30);
--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);
}

View File

@@ -0,0 +1,538 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fête de l'Humanité 2025 - Billets</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;800&family=Playfair+Display:wght@400;500;600;700&family=Fira+Code:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="festival_theme.css">
<style>
* {
margin: 0 !important;
padding: 0 !important;
box-sizing: border-box !important;
}
body {
font-family: var(--font-sans) !important;
background: var(--background) !important;
color: var(--foreground) !important;
line-height: 1.6 !important;
}
.festival-gradient {
background: linear-gradient(135deg,
oklch(0.4902 0.2314 320.7094) 0%,
oklch(0.6471 0.1686 342.5570) 50%,
oklch(0.7255 0.1451 51.2345) 100%) !important;
}
.ticket-card {
background: var(--card) !important;
border: 2px solid var(--border) !important;
border-radius: var(--radius-lg) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: var(--shadow) !important;
}
.ticket-card:hover {
transform: translateY(-4px) !important;
box-shadow: var(--shadow-lg) !important;
border-color: var(--primary) !important;
}
.ticket-card.selected {
border-color: var(--primary) !important;
background: linear-gradient(135deg, var(--card), oklch(0.4902 0.2314 320.7094 / 0.05)) !important;
box-shadow: var(--shadow-lg) !important;
}
.quantity-control {
background: var(--muted) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
transition: all 0.2s ease !important;
}
.quantity-control:hover {
background: var(--accent) !important;
transform: scale(1.05) !important;
}
.cart-summary {
background: linear-gradient(135deg,
var(--card),
oklch(0.4902 0.2314 320.7094 / 0.03)) !important;
border: 2px solid var(--primary) !important;
border-radius: var(--radius-xl) !important;
box-shadow: var(--shadow-md) !important;
}
.checkout-button {
background: var(--primary) !important;
color: var(--primary-foreground) !important;
border: none !important;
border-radius: var(--radius-lg) !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: var(--shadow) !important;
}
.checkout-button:hover:not(:disabled) {
background: oklch(0.4302 0.2314 320.7094) !important;
transform: translateY(-2px) !important;
box-shadow: var(--shadow-lg) !important;
}
.checkout-button:disabled {
background: var(--muted) !important;
color: var(--muted-foreground) !important;
cursor: not-allowed !important;
opacity: 0.5 !important;
}
.festival-info {
background: linear-gradient(45deg,
oklch(0.7255 0.1451 51.2345 / 0.1),
oklch(0.6471 0.1686 342.5570 / 0.1)) !important;
border-radius: var(--radius-lg) !important;
border: 1px solid var(--accent) !important;
}
.hero-section {
background: linear-gradient(135deg,
oklch(0.4902 0.2314 320.7094 / 0.9) 0%,
oklch(0.6471 0.1686 342.5570 / 0.9) 50%,
oklch(0.7255 0.1451 51.2345 / 0.9) 100%),
url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=1200&h=600&fit=crop') !important;
background-size: cover !important;
background-position: center !important;
color: white !important;
}
.animate-bounce-slow {
animation: bounce 2s infinite !important;
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite !important;
}
.ripple-effect {
position: relative !important;
overflow: hidden !important;
}
.ripple-effect::after {
content: '' !important;
position: absolute !important;
top: 50% !important;
left: 50% !important;
width: 0 !important;
height: 0 !important;
border-radius: 50% !important;
background: rgba(255, 255, 255, 0.3) !important;
transform: translate(-50%, -50%) !important;
transition: width 0.4s, height 0.4s !important;
}
.ripple-effect:hover::after {
width: 100% !important;
height: 100% !important;
}
</style>
</head>
<body class="bg-gray-50">
<!-- Hero Section -->
<section class="hero-section h-96 flex items-center justify-center relative overflow-hidden">
<div class="absolute inset-0 bg-black bg-opacity-40"></div>
<div class="relative z-10 text-center max-w-4xl mx-auto px-4">
<h1 class="text-5xl md:text-6xl font-bold mb-4 font-serif animate-pulse-slow">Fête de l'Humanité 2025</h1>
<p class="text-xl md:text-2xl mb-2 opacity-90">14-16 Septembre • La Courneuve</p>
<p class="text-lg opacity-80 max-w-2xl mx-auto">Trois jours de musique, débats, culture et solidarité au cœur du plus grand festival populaire de France</p>
<div class="flex justify-center items-center mt-6 space-x-6">
<div class="flex items-center">
<i data-lucide="calendar" class="w-5 h-5 mr-2"></i>
<span>3 jours</span>
</div>
<div class="flex items-center">
<i data-lucide="music" class="w-5 h-5 mr-2"></i>
<span>100+ concerts</span>
</div>
<div class="flex items-center">
<i data-lucide="users" class="w-5 h-5 mr-2"></i>
<span>500k visiteurs</span>
</div>
</div>
</div>
<div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 animate-bounce-slow">
<i data-lucide="chevron-down" class="w-8 h-8 text-white opacity-70"></i>
</div>
</section>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Ticket Selection Hub -->
<div class="mb-12">
<div class="text-center mb-10">
<h2 class="text-4xl font-bold text-gray-900 mb-4 font-serif">Choisissez vos billets</h2>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">Découvrez nos différentes formules pour profiter pleinement du festival</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left Column: Tickets -->
<div class="lg:col-span-2">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 mb-8">
<!-- Pass 3 Jours -->
<div class="ticket-card p-6 cursor-pointer ripple-effect" onclick="selectTicket('pass3j', 45, 'Pass 3 jours')">
<div class="text-center">
<div class="festival-gradient w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="star" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Pass 3 Jours</h3>
<p class="text-sm text-gray-600 mb-4">Accès complet au festival</p>
<div class="text-3xl font-bold text-primary mb-4">45€</div>
<div class="text-sm text-green-600 font-medium mb-4">✓ Disponible</div>
<div class="flex items-center justify-center space-x-3">
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('pass3j', -1)">
<i data-lucide="minus" class="w-4 h-4"></i>
</button>
<span class="w-8 text-center font-medium" id="pass3j-qty">0</span>
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('pass3j', 1)">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- Samedi 14 -->
<div class="ticket-card p-6 cursor-pointer ripple-effect" onclick="selectTicket('samedi', 18, 'Samedi 14 Sept')">
<div class="text-center">
<div class="bg-gradient-to-br from-purple-500 to-pink-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="calendar-days" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Samedi 14</h3>
<p class="text-sm text-gray-600 mb-4">Journée complète</p>
<div class="text-3xl font-bold text-primary mb-4">18€</div>
<div class="text-sm text-green-600 font-medium mb-4">✓ Disponible</div>
<div class="flex items-center justify-center space-x-3">
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('samedi', -1)">
<i data-lucide="minus" class="w-4 h-4"></i>
</button>
<span class="w-8 text-center font-medium" id="samedi-qty">0</span>
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('samedi', 1)">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- Dimanche 15 -->
<div class="ticket-card p-6 cursor-pointer ripple-effect" onclick="selectTicket('dimanche', 18, 'Dimanche 15 Sept')">
<div class="text-center">
<div class="bg-gradient-to-br from-orange-500 to-red-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="sun" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Dimanche 15</h3>
<p class="text-sm text-gray-600 mb-4">Journée complète</p>
<div class="text-3xl font-bold text-primary mb-4">18€</div>
<div class="text-sm text-green-600 font-medium mb-4">✓ Disponible</div>
<div class="flex items-center justify-center space-x-3">
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('dimanche', -1)">
<i data-lucide="minus" class="w-4 h-4"></i>
</button>
<span class="w-8 text-center font-medium" id="dimanche-qty">0</span>
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('dimanche', 1)">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- Lundi 16 -->
<div class="ticket-card p-6 cursor-pointer ripple-effect" onclick="selectTicket('lundi', 18, 'Lundi 16 Sept')">
<div class="text-center">
<div class="bg-gradient-to-br from-green-500 to-blue-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="moon" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Lundi 16</h3>
<p class="text-sm text-gray-600 mb-4">Journée complète</p>
<div class="text-3xl font-bold text-primary mb-4">18€</div>
<div class="text-sm text-green-600 font-medium mb-4">✓ Disponible</div>
<div class="flex items-center justify-center space-x-3">
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('lundi', -1)">
<i data-lucide="minus" class="w-4 h-4"></i>
</button>
<span class="w-8 text-center font-medium" id="lundi-qty">0</span>
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('lundi', 1)">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- Tarif Réduit -->
<div class="ticket-card p-6 cursor-pointer ripple-effect" onclick="selectTicket('reduit', 12, 'Tarif Réduit')">
<div class="text-center">
<div class="bg-gradient-to-br from-yellow-500 to-orange-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="percent" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Tarif Réduit</h3>
<p class="text-sm text-gray-600 mb-4">Étudiants, -26 ans, RSA</p>
<div class="text-3xl font-bold text-primary mb-4">12€</div>
<div class="text-sm text-green-600 font-medium mb-4">✓ Disponible</div>
<div class="flex items-center justify-center space-x-3">
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('reduit', -1)">
<i data-lucide="minus" class="w-4 h-4"></i>
</button>
<span class="w-8 text-center font-medium" id="reduit-qty">0</span>
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('reduit', 1)">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- Gratuit -12 ans -->
<div class="ticket-card p-6 cursor-pointer ripple-effect" onclick="selectTicket('gratuit', 0, 'Gratuit -12 ans')">
<div class="text-center">
<div class="bg-gradient-to-br from-green-600 to-emerald-600 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="gift" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Gratuit</h3>
<p class="text-sm text-gray-600 mb-4">Enfants -12 ans</p>
<div class="text-3xl font-bold text-green-600 mb-4">Gratuit</div>
<div class="text-sm text-green-600 font-medium mb-4">✓ Disponible</div>
<div class="flex items-center justify-center space-x-3">
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('gratuit', -1)">
<i data-lucide="minus" class="w-4 h-4"></i>
</button>
<span class="w-8 text-center font-medium" id="gratuit-qty">0</span>
<button class="quantity-control w-10 h-10 flex items-center justify-center" onclick="event.stopPropagation(); changeQuantity('gratuit', 1)">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Cart & Info -->
<div class="lg:col-span-1">
<!-- Cart Summary -->
<div class="cart-summary p-6 mb-8 sticky top-4">
<h3 class="text-2xl font-bold text-gray-900 mb-6 text-center">Récapitulatif</h3>
<div id="cart-items" class="space-y-3 mb-6 min-h-[100px]">
<div class="text-center text-gray-500 py-8">
<i data-lucide="shopping-cart" class="w-12 h-12 mx-auto mb-4 opacity-50"></i>
<p>Votre panier est vide</p>
</div>
</div>
<div class="border-t border-gray-200 pt-4 space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Total billets:</span>
<span class="font-medium" id="total-quantity">0</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Sous-total:</span>
<span class="font-medium" id="subtotal">€0.00</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Frais de service:</span>
<span class="font-medium" id="service-fee">€0.00</span>
</div>
<div class="border-t border-gray-300 pt-2 mt-4">
<div class="flex justify-between text-lg font-bold">
<span>TOTAL:</span>
<span class="text-primary" id="total-amount">€0.00</span>
</div>
</div>
</div>
<button id="checkout-btn" class="checkout-button w-full py-4 px-6 text-lg font-semibold mt-6 disabled" disabled>
<i data-lucide="credit-card" class="w-5 h-5 inline-block mr-2"></i>
Finaliser la commande
</button>
</div>
<!-- Festival Info -->
<div class="festival-info p-6">
<h4 class="text-xl font-bold text-gray-900 mb-4 text-center">🎪 Festival Highlights</h4>
<div class="space-y-3 text-sm">
<div class="flex items-center">
<i data-lucide="music" class="w-5 h-5 mr-3 text-purple-600"></i>
<span>100+ concerts et spectacles</span>
</div>
<div class="flex items-center">
<i data-lucide="mic" class="w-5 h-5 mr-3 text-purple-600"></i>
<span>Débats et conférences</span>
</div>
<div class="flex items-center">
<i data-lucide="utensils" class="w-5 h-5 mr-3 text-purple-600"></i>
<span>Village gastronomique</span>
</div>
<div class="flex items-center">
<i data-lucide="heart" class="w-5 h-5 mr-3 text-purple-600"></i>
<span>Village solidaire</span>
</div>
<div class="flex items-center">
<i data-lucide="gamepad-2" class="w-5 h-5 mr-3 text-purple-600"></i>
<span>Animations jeunesse</span>
</div>
<div class="flex items-center">
<i data-lucide="train" class="w-5 h-5 mr-3 text-purple-600"></i>
<span>Accès RER B La Courneuve</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Cart state
let cart = {};
const serviceFeeRate = 0.05; // 5% service fee
function selectTicket(id, price, name) {
// Visual selection effect
const cards = document.querySelectorAll('.ticket-card');
cards.forEach(card => card.classList.remove('selected'));
event.currentTarget.classList.add('selected');
// Auto-add one ticket if none selected
if (!cart[id] || cart[id].quantity === 0) {
changeQuantity(id, 1, price, name);
}
}
function changeQuantity(id, delta, price, name) {
if (!cart[id]) {
cart[id] = { quantity: 0, price: price || 0, name: name || '' };
}
// Get price and name from ticket data if not provided
if (!price) {
const ticketPrices = {
'pass3j': { price: 45, name: 'Pass 3 jours' },
'samedi': { price: 18, name: 'Samedi 14 Sept' },
'dimanche': { price: 18, name: 'Dimanche 15 Sept' },
'lundi': { price: 18, name: 'Lundi 16 Sept' },
'reduit': { price: 12, name: 'Tarif Réduit' },
'gratuit': { price: 0, name: 'Gratuit -12 ans' }
};
price = ticketPrices[id].price;
name = ticketPrices[id].name;
cart[id].price = price;
cart[id].name = name;
}
cart[id].quantity = Math.max(0, cart[id].quantity + delta);
// Update quantity display
document.getElementById(id + '-qty').textContent = cart[id].quantity;
// Remove from cart if quantity is 0
if (cart[id].quantity === 0) {
delete cart[id];
}
updateCartSummary();
}
function updateCartSummary() {
const cartItemsContainer = document.getElementById('cart-items');
const totalQuantityEl = document.getElementById('total-quantity');
const subtotalEl = document.getElementById('subtotal');
const serviceFeeEl = document.getElementById('service-fee');
const totalAmountEl = document.getElementById('total-amount');
const checkoutBtn = document.getElementById('checkout-btn');
let totalQuantity = 0;
let subtotal = 0;
let cartItemsHtml = '';
// Check if cart is empty
const hasItems = Object.keys(cart).some(id => cart[id].quantity > 0);
if (!hasItems) {
cartItemsHtml = `
<div class="text-center text-gray-500 py-8">
<i data-lucide="shopping-cart" class="w-12 h-12 mx-auto mb-4 opacity-50"></i>
<p>Votre panier est vide</p>
</div>
`;
checkoutBtn.disabled = true;
checkoutBtn.classList.add('disabled');
} else {
// Build cart items
Object.keys(cart).forEach(id => {
if (cart[id].quantity > 0) {
totalQuantity += cart[id].quantity;
subtotal += cart[id].quantity * cart[id].price;
cartItemsHtml += `
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
<div class="flex-1">
<div class="font-medium text-sm">${cart[id].name}</div>
<div class="text-xs text-gray-500">${cart[id].quantity} ×${cart[id].price.toFixed(2)}</div>
</div>
<div class="font-medium text-sm">€${(cart[id].quantity * cart[id].price).toFixed(2)}</div>
</div>
`;
}
});
checkoutBtn.disabled = false;
checkoutBtn.classList.remove('disabled');
}
const serviceFee = subtotal * serviceFeeRate;
const totalAmount = subtotal + serviceFee;
cartItemsContainer.innerHTML = cartItemsHtml;
totalQuantityEl.textContent = totalQuantity;
subtotalEl.textContent = `${subtotal.toFixed(2)}`;
serviceFeeEl.textContent = `${serviceFee.toFixed(2)}`;
totalAmountEl.textContent = `${totalAmount.toFixed(2)}`;
// Recreate icons for newly added elements
lucide.createIcons();
}
// Checkout button click handler
document.getElementById('checkout-btn').addEventListener('click', function() {
if (this.disabled) return;
// Simulate checkout process
this.innerHTML = '<i data-lucide="loader-2" class="w-5 h-5 inline-block mr-2 animate-spin"></i>Traitement...';
this.disabled = true;
setTimeout(() => {
alert('Redirection vers le paiement sécurisé...');
this.innerHTML = '<i data-lucide="credit-card" class="w-5 h-5 inline-block mr-2"></i>Finaliser la commande';
this.disabled = Object.keys(cart).length === 0;
lucide.createIcons();
}, 2000);
});
// Initial setup
updateCartSummary();
</script>
</body>
</html>

View File

@@ -1,627 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APERONIGHT - RADICAL EVENT BOOKING</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<link rel="stylesheet" href="neo_brutalist_theme.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
line-height: 1.4;
color: var(--foreground);
background: var(--background);
overflow-x: hidden;
}
.hero {
background: repeating-linear-gradient(
45deg,
var(--secondary) 0px,
var(--secondary) 20px,
transparent 20px,
transparent 40px
), var(--background);
padding: 100px 0;
text-align: center;
position: relative;
border-bottom: 6px solid var(--border);
box-shadow: 0 6px 0px 0px var(--accent);
}
.hero::before {
content: '';
position: absolute;
top: 20px;
left: 20px;
right: 20px;
bottom: 20px;
border: 4px solid var(--border);
pointer-events: none;
}
.hero h1 {
font-size: clamp(2.5rem, 8vw, 6rem);
font-weight: 900;
margin-bottom: var(--space-8);
color: var(--foreground);
text-transform: uppercase;
letter-spacing: -0.02em;
text-shadow: 4px 4px 0px var(--accent);
animation: glitch 3s infinite;
}
@keyframes glitch {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-2px); }
40% { transform: translateX(2px); }
60% { transform: translateX(-1px); }
80% { transform: translateX(1px); }
}
.hero p {
font-size: var(--text-2xl);
font-weight: 700;
margin-bottom: var(--space-12);
max-width: 800px;
margin-left: auto;
margin-right: auto;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.cta-section {
display: flex;
gap: var(--space-8);
justify-content: center;
flex-wrap: wrap;
align-items: center;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-4);
position: relative;
}
.section {
padding: var(--space-20) 0;
position: relative;
}
.section:nth-child(odd) {
background: linear-gradient(135deg, var(--secondary) 25%, transparent 25%),
linear-gradient(225deg, var(--secondary) 25%, transparent 25%),
linear-gradient(45deg, var(--secondary) 25%, transparent 25%),
linear-gradient(315deg, var(--secondary) 25%, var(--background) 25%);
background-size: 40px 40px;
background-position: 0 0, 0 20px, 20px -20px, -20px 0px;
}
.section-title {
font-size: clamp(2rem, 6vw, 4rem);
font-weight: 900;
text-align: center;
margin-bottom: var(--space-16);
color: var(--foreground);
text-transform: uppercase;
position: relative;
}
.section-title::after {
content: '';
display: block;
width: 100px;
height: 8px;
background: var(--accent);
margin: var(--space-4) auto 0;
box-shadow: var(--shadow-brutal);
}
.events-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: var(--space-12);
margin-bottom: var(--space-16);
}
.brutal-event-card {
background: var(--background);
border: 4px solid var(--border);
position: relative;
transition: all 0.1s ease;
overflow: visible;
}
.brutal-event-card::before {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -20px;
bottom: -20px;
background: var(--primary);
z-index: -1;
transition: all 0.1s ease;
}
.brutal-event-card:hover {
transform: translate(8px, 8px);
}
.brutal-event-card:hover::before {
transform: translate(-8px, -8px);
background: var(--accent);
}
.event-image {
width: 100%;
height: 250px;
object-fit: cover;
border-bottom: 4px solid var(--border);
}
.event-content {
padding: var(--space-8);
}
.event-title {
font-size: var(--text-2xl);
font-weight: 900;
margin-bottom: var(--space-4);
color: var(--foreground);
text-transform: uppercase;
letter-spacing: -0.02em;
}
.event-date {
color: var(--foreground);
font-size: var(--text-lg);
font-weight: 700;
margin-bottom: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.event-description {
color: var(--foreground);
margin-bottom: var(--space-6);
font-weight: 700;
line-height: 1.3;
}
.event-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-4);
}
.event-price {
font-weight: 900;
font-size: var(--text-2xl);
color: var(--foreground);
background: var(--secondary);
padding: var(--space-2) var(--space-4);
border: 3px solid var(--border);
box-shadow: var(--shadow-brutal);
text-transform: uppercase;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--space-12);
}
.brutal-feature {
text-align: center;
padding: var(--space-8);
background: var(--background);
border: 4px solid var(--border);
position: relative;
box-shadow: var(--shadow-brutal-lg);
}
.brutal-feature::after {
content: '';
position: absolute;
top: -12px;
left: -12px;
right: -24px;
bottom: -24px;
background: repeating-linear-gradient(
45deg,
var(--accent) 0px,
var(--accent) 10px,
transparent 10px,
transparent 20px
);
z-index: -1;
}
.feature-icon {
width: 80px;
height: 80px;
background: var(--primary);
color: white;
border: 4px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--space-6);
box-shadow: var(--shadow-brutal);
}
.brutal-feature h3 {
font-size: var(--text-2xl);
font-weight: 900;
margin-bottom: var(--space-4);
color: var(--foreground);
text-transform: uppercase;
letter-spacing: -0.02em;
}
.brutal-feature p {
color: var(--foreground);
font-weight: 700;
line-height: 1.3;
}
.stats {
background: var(--foreground);
color: var(--background);
padding: var(--space-20) 0;
border-top: 6px solid var(--accent);
border-bottom: 6px solid var(--accent);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-12);
text-align: center;
}
.stat-item {
padding: var(--space-8);
background: var(--background);
color: var(--foreground);
border: 4px solid var(--background);
box-shadow: var(--shadow-brutal-xl);
position: relative;
}
.stat-item::before {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -16px;
bottom: -16px;
background: var(--accent);
z-index: -1;
}
.stat-number {
font-size: clamp(2.5rem, 6vw, 4rem);
font-weight: 900;
color: var(--primary);
display: block;
text-transform: uppercase;
letter-spacing: -0.02em;
}
.stat-label {
color: var(--foreground);
margin-top: var(--space-2);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: var(--text-lg);
}
.footer {
background: var(--border);
color: var(--background);
padding: var(--space-16) 0;
border-top: 6px solid var(--primary);
}
.footer-content {
text-align: center;
}
.footer-links {
display: flex;
justify-content: center;
gap: var(--space-8);
margin-bottom: var(--space-8);
flex-wrap: wrap;
}
.footer-links a {
color: var(--background);
text-decoration: none;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: var(--space-2) var(--space-4);
border: 3px solid var(--background);
transition: all 0.1s ease;
}
.footer-links a:hover {
background: var(--background);
color: var(--border);
box-shadow: 4px 4px 0px 0px var(--accent);
transform: translate(-2px, -2px);
}
.noise-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.03;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='1' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 100;
}
@media (max-width: 768px) {
.cta-section {
flex-direction: column;
gap: var(--space-6);
}
.events-grid {
grid-template-columns: 1fr;
gap: var(--space-8);
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-6);
}
.footer-links {
flex-direction: column;
gap: var(--space-4);
}
}
</style>
</head>
<body>
<div class="noise-overlay"></div>
<!-- Navigation -->
<nav class="nav">
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--space-4) 0;">
<div style="display: flex; align-items: center; gap: var(--space-4);">
<div style="width: 50px; height: 50px; background: var(--primary); border: 4px solid var(--border); display: flex; align-items: center; justify-content: center; color: white; font-weight: 900; box-shadow: var(--shadow-brutal); font-size: var(--text-xl);">A</div>
<span style="font-size: var(--text-2xl); font-weight: 900; color: var(--foreground); text-transform: uppercase; letter-spacing: -0.02em;">APERONIGHT</span>
</div>
<div style="display: flex; gap: var(--space-4); align-items: center;">
<a href="#" class="nav-link">EVENTS</a>
<a href="#" class="nav-link">ABOUT</a>
<a href="#" class="nav-link">CONTACT</a>
<button class="btn-primary">SIGN IN</button>
</div>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="hero">
<div class="container">
<h1 class="glitch-text" data-text="RADICAL EVENTS">RADICAL EVENTS</h1>
<p>BREAK THE BORING. JOIN THE REVOLUTION. EXPERIENCE EVENTS THAT MATTER.</p>
<div class="cta-section">
<button class="btn-primary">FIND EVENTS</button>
<div style="width: 4px; height: 60px; background: var(--border); box-shadow: var(--shadow-brutal);"></div>
<button class="btn-secondary">HOST EVENT</button>
</div>
</div>
</section>
<!-- Featured Events -->
<section class="section">
<div class="container">
<h2 class="section-title">FEATURED CHAOS</h2>
<div class="events-grid">
<div class="brutal-event-card">
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=250&fit=crop" alt="TECH CHAOS" class="event-image">
<div class="event-content">
<h3 class="event-title">TECH CHAOS NIGHT</h3>
<div class="event-date">
<i data-lucide="zap" style="width: 24px; height: 24px;"></i>
THU MAR 15 • 6PM
</div>
<p class="event-description">SMASH NETWORKING BARRIERS. CODE. DRINKS. CHAOS. REPEAT.</p>
<div class="event-footer">
<span class="event-price">€25</span>
<div style="display: flex; gap: var(--space-3);">
<span class="badge-available">LIVE</span>
<button class="btn-destructive" style="padding: var(--space-3) var(--space-4);">GRAB IT</button>
</div>
</div>
</div>
</div>
<div class="brutal-event-card">
<img src="https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=250&fit=crop" alt="SOUND ASSAULT" class="event-image">
<div class="event-content">
<h3 class="event-title">SOUND ASSAULT</h3>
<div class="event-date">
<i data-lucide="volume-2" style="width: 24px; height: 24px;"></i>
SAT MAR 18 • 8PM
</div>
<p class="event-description">UNDERGROUND BEATS. ROOF ACCESS. CITY DOMINATION.</p>
<div class="event-footer">
<span class="event-price">€35</span>
<div style="display: flex; gap: var(--space-3);">
<span class="badge-featured">★ HOT</span>
<button class="btn-primary" style="padding: var(--space-3) var(--space-4);">INVADE</button>
</div>
</div>
</div>
</div>
<div class="brutal-event-card">
<img src="https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=400&h=250&fit=crop" alt="ART REBELLION" class="event-image">
<div class="event-content">
<h3 class="event-title">ART REBELLION</h3>
<div class="event-date">
<i data-lucide="paintbrush" style="width: 24px; height: 24px;"></i>
FRI MAR 22 • 7PM
</div>
<p class="event-description">DESTROY CONVENTIONS. CREATE CHAOS. WINE INCLUDED.</p>
<div class="event-footer">
<span class="event-price">€20</span>
<div style="display: flex; gap: var(--space-3);">
<span class="badge-sold-out">DANGER</span>
<button class="btn-secondary" style="padding: var(--space-3) var(--space-4);">RISK IT</button>
</div>
</div>
</div>
</div>
</div>
<div style="text-align: center;">
<button class="btn-secondary" style="font-size: var(--text-xl); padding: var(--space-4) var(--space-8);">MORE CHAOS</button>
</div>
</div>
</section>
<!-- Features -->
<section class="section">
<div class="container">
<h2 class="section-title">WHY WE RULE</h2>
<div class="features-grid">
<div class="brutal-feature">
<div class="feature-icon">
<i data-lucide="skull" style="width: 40px; height: 40px;"></i>
</div>
<h3>CURATED MADNESS</h3>
<p>HANDPICKED EVENTS THAT DESTROY BORING AND CREATE LEGENDS.</p>
</div>
<div class="brutal-feature">
<div class="feature-icon">
<i data-lucide="shield" style="width: 40px; height: 40px;"></i>
</div>
<h3>BULLETPROOF BOOKING</h3>
<p>SECURE PAYMENTS. INSTANT TICKETS. NO BULLSHIT REFUNDS.</p>
</div>
<div class="brutal-feature">
<div class="feature-icon">
<i data-lucide="rocket" style="width: 40px; height: 40px;"></i>
</div>
<h3>ZERO FRICTION</h3>
<p>FIND EVENT. BOOK TICKET. DESTROY EXPECTATIONS. REPEAT.</p>
</div>
<div class="brutal-feature">
<div class="feature-icon">
<i data-lucide="users" style="width: 40px; height: 40px;"></i>
</div>
<h3>TRIBE BUILDING</h3>
<p>CONNECT WITH REBELS WHO GET IT. BUILD YOUR EMPIRE.</p>
</div>
</div>
</div>
</section>
<!-- Stats -->
<section class="stats">
<div class="container">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-number">50+</span>
<div class="stat-label">EVENTS MONTHLY</div>
</div>
<div class="stat-item">
<span class="stat-number">2.5K</span>
<div class="stat-label">REBELS JOINED</div>
</div>
<div class="stat-item">
<span class="stat-number">100+</span>
<div class="stat-label">VENUES CONQUERED</div>
</div>
<div class="stat-item">
<span class="stat-number">95%</span>
<div class="stat-label">MINDS BLOWN</div>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-content">
<div class="footer-links">
<a href="#">ABOUT</a>
<a href="#">EVENTS</a>
<a href="#">SUPPORT</a>
<a href="#">PRIVACY</a>
<a href="#">TERMS</a>
</div>
<p style="font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em;">&copy; 2024 APERONIGHT. CHAOS RESERVED.</p>
</div>
</div>
</footer>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Add brutal animations on scroll
const observerOptions = {
threshold: 0.2,
rootMargin: '0px 0px -100px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.style.animation = 'none';
entry.target.offsetHeight; // Trigger reflow
entry.target.style.animation = 'shake-brutal 0.5s ease-in-out';
}
});
}, observerOptions);
document.querySelectorAll('.brutal-event-card, .brutal-feature, .stat-item').forEach(el => {
observer.observe(el);
});
// Add random glitch effects
setInterval(() => {
const elements = document.querySelectorAll('.section-title, .event-title');
const randomElement = elements[Math.floor(Math.random() * elements.length)];
if (randomElement && Math.random() > 0.9) {
randomElement.style.animation = 'glitch 0.3s ease-in-out';
setTimeout(() => {
randomElement.style.animation = '';
}, 300);
}
}, 2000);
</script>
</body>
</html>

View File

@@ -1,417 +0,0 @@
/* Neo-Brutalist Design System for Event Booking */
:root {
/* Colors - Bold and high contrast */
--background: #ffffff;
--foreground: #000000;
--card: #ffffff;
--card-foreground: #000000;
--popover: #ffffff;
--popover-foreground: #000000;
--primary: #ff6b35;
--primary-foreground: #ffffff;
--secondary: #00f5ff;
--secondary-foreground: #000000;
--muted: #f0f0f0;
--muted-foreground: #333333;
--accent: #ff1744;
--accent-foreground: #ffffff;
--destructive: #000000;
--destructive-foreground: #ffffff;
--border: #000000;
--input: #ffffff;
--ring: #ff6b35;
/* Event-specific colors - Bold and vibrant */
--event-featured: #7c4dff;
--event-sold-out: #000000;
--event-available: #00c853;
--ticket-premium: #ffc107;
--ticket-standard: #9e9e9e;
/* Typography - Bold and impactful */
--font-sans: 'Space Grotesk', 'Arial Black', sans-serif;
--font-mono: 'Space Mono', 'Courier New', monospace;
/* Font sizes - Exaggerated scale */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
--text-3xl: 2.5rem;
--text-4xl: 3.5rem;
/* Spacing - Generous */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
/* Border radius - Sharp edges */
--radius: 0px;
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
--radius-xl: 0px;
/* Shadows - Bold and offset */
--shadow-brutal: 8px 8px 0px 0px #000000;
--shadow-brutal-lg: 12px 12px 0px 0px #000000;
--shadow-brutal-xl: 16px 16px 0px 0px #000000;
--shadow-brutal-color: 8px 8px 0px 0px var(--accent);
--shadow-inset: inset 4px 4px 0px 0px rgba(0,0,0,0.2);
}
/* Typography overrides */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans) !important;
font-weight: 900 !important;
text-transform: uppercase !important;
letter-spacing: -0.02em !important;
}
h1 { font-size: var(--text-4xl) !important; }
h2 { font-size: var(--text-3xl) !important; }
h3 { font-size: var(--text-2xl) !important; }
/* Component styles */
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
padding: 16px 32px;
border-radius: var(--radius);
font-weight: 900;
border: 3px solid var(--border);
cursor: pointer;
transition: all 0.1s ease;
box-shadow: var(--shadow-brutal);
font-family: var(--font-sans);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-primary:hover {
transform: translate(4px, 4px);
box-shadow: 4px 4px 0px 0px #000000;
}
.btn-primary:active {
transform: translate(8px, 8px);
box-shadow: none;
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
padding: 16px 32px;
border-radius: var(--radius);
font-weight: 900;
border: 3px solid var(--border);
cursor: pointer;
transition: all 0.1s ease;
box-shadow: var(--shadow-brutal);
font-family: var(--font-sans);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-secondary:hover {
transform: translate(4px, 4px);
box-shadow: 4px 4px 0px 0px #000000;
}
.btn-destructive {
background: var(--destructive);
color: var(--destructive-foreground);
padding: 16px 32px;
border-radius: var(--radius);
font-weight: 900;
border: 3px solid var(--border);
cursor: pointer;
transition: all 0.1s ease;
box-shadow: var(--shadow-brutal-color);
font-family: var(--font-sans);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow-brutal-lg);
padding: var(--space-8);
border: 4px solid var(--border);
transition: all 0.1s ease;
position: relative;
}
.card::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -16px;
bottom: -16px;
background: var(--accent);
z-index: -1;
}
.card:hover {
transform: translate(4px, 4px);
box-shadow: 8px 8px 0px 0px #000000;
}
.event-card {
background: var(--card);
border-radius: var(--radius);
overflow: visible;
box-shadow: var(--shadow-brutal-xl);
transition: all 0.1s ease;
border: 4px solid var(--border);
position: relative;
}
.event-card::after {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -24px;
bottom: -24px;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
var(--secondary) 10px,
var(--secondary) 20px
);
z-index: -1;
opacity: 0;
transition: opacity 0.2s ease;
}
.event-card:hover::after {
opacity: 1;
}
.event-card:hover {
transform: translate(8px, 8px);
box-shadow: 8px 8px 0px 0px #000000;
}
.ticket-card {
background: var(--muted);
border-radius: var(--radius);
padding: var(--space-6);
border: 3px solid var(--border);
transition: all 0.1s ease;
box-shadow: var(--shadow-brutal);
position: relative;
}
.ticket-card.selected {
background: var(--primary);
color: var(--primary-foreground);
transform: translate(4px, 4px);
box-shadow: 4px 4px 0px 0px #000000;
}
.ticket-card.selected::before {
content: '✓';
position: absolute;
top: -10px;
right: -10px;
background: var(--accent);
color: white;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
border: 3px solid var(--border);
box-shadow: 4px 4px 0px 0px #000000;
}
.form-input {
background: var(--input);
border: 3px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
font-size: var(--text-lg);
font-weight: 700;
transition: all 0.1s ease;
box-shadow: var(--shadow-inset);
font-family: var(--font-mono);
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--shadow-brutal-color);
transform: translate(-2px, -2px);
}
.badge {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: var(--radius);
font-size: var(--text-sm);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.1em;
border: 2px solid var(--border);
box-shadow: 4px 4px 0px 0px #000000;
font-family: var(--font-sans);
}
.badge-available {
background: var(--event-available);
color: #000000;
}
.badge-sold-out {
background: var(--event-sold-out);
color: #ffffff;
}
.badge-featured {
background: var(--event-featured);
color: #ffffff;
animation: pulse-brutal 2s infinite;
}
.badge-premium {
background: var(--ticket-premium);
color: #000000;
position: relative;
}
.badge-premium::after {
content: '★';
margin-left: 8px;
animation: rotate 3s linear infinite;
}
/* Navigation */
.nav {
background: var(--background);
border-bottom: 6px solid var(--border);
box-shadow: 0 6px 0px 0px var(--accent);
}
.nav-link {
color: var(--foreground);
font-weight: 900;
padding: 12px 20px;
border-radius: var(--radius);
transition: all 0.1s ease;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 3px solid transparent;
font-family: var(--font-sans);
}
.nav-link:hover, .nav-link.active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--border);
box-shadow: 4px 4px 0px 0px #000000;
transform: translate(-2px, -2px);
}
/* Animations */
@keyframes pulse-brutal {
0%, 100% {
transform: scale(1);
box-shadow: 4px 4px 0px 0px #000000;
}
50% {
transform: scale(1.05);
box-shadow: 6px 6px 0px 0px #000000;
}
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes shake-brutal {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
75% { transform: translateX(8px); }
}
.animate-shake {
animation: shake-brutal 0.5s ease-in-out;
}
/* Special effects */
.glitch-text {
position: relative;
color: var(--primary);
font-weight: 900;
}
.glitch-text::before,
.glitch-text::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
overflow: hidden;
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
}
.glitch-text::before {
color: var(--accent);
animation: glitch-1 2s infinite;
}
.glitch-text::after {
color: var(--secondary);
animation: glitch-2 2s infinite;
}
@keyframes glitch-1 {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-2px); }
}
@keyframes glitch-2 {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(2px); }
}
/* Responsive design */
@media (max-width: 768px) {
:root {
--space-4: 0.75rem;
--space-6: 1rem;
--space-8: 1.5rem;
--text-4xl: 2.5rem;
--text-3xl: 2rem;
}
.btn-primary, .btn-secondary, .btn-destructive {
padding: 12px 24px;
font-size: var(--text-sm);
}
.card {
padding: var(--space-4);
}
.shadow-brutal, .shadow-brutal-lg, .shadow-brutal-xl {
box-shadow: 4px 4px 0px 0px #000000;
}
}

View File

@@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - Quantic Telecom</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="quantic_telecom_theme.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans) !important;
background: var(--gradient-background) !important;
min-height: 100vh !important;
position: relative !important;
overflow-x: hidden !important;
}
/* Background grid pattern */
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: 50px 50px;
opacity: 0.3;
z-index: 0;
animation: gridShift 20s linear infinite;
}
@keyframes gridShift {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
}
/* Page entrance animation */
.page-container {
animation: pageLoad 800ms ease-out forwards;
opacity: 0;
transform: translateY(40px);
}
@keyframes pageLoad {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Logo animation */
.logo-container {
animation: logoFade 1200ms ease-out 200ms forwards;
opacity: 0;
transform: scale(0.8);
}
@keyframes logoFade {
to {
opacity: 1;
transform: scale(1);
}
}
/* Card slide animation */
.login-card {
background: var(--glass-bg) !important;
backdrop-filter: var(--glass-backdrop) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-xl) !important;
animation: cardSlide 600ms cubic-bezier(0.4, 0, 0.2, 1) 400ms forwards;
opacity: 0;
transform: translateY(30px);
transition: all 300ms ease-out;
}
.login-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-2xl);
}
@keyframes cardSlide {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Input field styling */
.input-group {
position: relative;
margin-bottom: 1.5rem;
}
.input-field {
width: 100%;
padding: 1rem 3rem 1rem 1rem;
border: 2px solid var(--border);
border-radius: var(--radius-md);
background: var(--input);
color: var(--foreground);
font-size: 1rem;
transition: all 200ms ease-out;
outline: none;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--ring);
transform: scale(1.01);
}
.input-field:focus + .floating-label {
transform: translateY(-10px) scale(0.75);
color: var(--primary);
}
.floating-label {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
background: var(--input);
padding: 0 0.5rem;
color: var(--muted-foreground);
pointer-events: none;
transition: all 200ms ease-out;
}
.input-field:not(:placeholder-shown) + .floating-label {
transform: translateY(-10px) scale(0.75);
}
/* Password toggle */
.password-toggle {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--muted-foreground);
cursor: pointer;
transition: all 150ms ease-out;
opacity: 0.6;
}
.password-toggle:hover {
opacity: 1;
transform: translateY(-50%) rotate(90deg);
}
/* Button styling */
.login-button {
width: 100%;
padding: 1rem;
background: var(--gradient-primary) !important;
border: none;
border-radius: var(--radius-md);
color: var(--primary-foreground);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 200ms ease-out;
position: relative;
overflow: hidden;
}
.login-button:hover {
transform: scale(1.02);
box-shadow: var(--shadow-lg);
}
.login-button:active {
transform: scale(0.98);
}
/* Ripple effect */
.login-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transition: width 400ms ease-out, height 400ms ease-out;
transform: translate(-50%, -50%);
}
.login-button:active::before {
width: 300px;
height: 300px;
}
/* Checkbox styling */
.custom-checkbox {
appearance: none;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
background: var(--input);
cursor: pointer;
position: relative;
transition: all 200ms ease-out;
}
.custom-checkbox:checked {
background: var(--primary);
border-color: var(--primary);
animation: checkboxTick 250ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.custom-checkbox:checked::before {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--primary-foreground);
font-size: 0.875rem;
font-weight: bold;
}
@keyframes checkboxTick {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Link styling */
.forgot-link {
color: var(--accent);
text-decoration: none;
position: relative;
transition: all 200ms ease-out;
}
.forgot-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width 200ms ease-out;
}
.forgot-link:hover::after {
width: 100%;
}
/* Validation states */
.input-error {
border-color: var(--destructive) !important;
animation: errorShake 300ms ease-out;
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
75% { transform: translateX(8px); }
}
.input-success {
border-color: var(--success) !important;
animation: successPulse 500ms ease-out;
}
@keyframes successPulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
/* Loading states */
.skeleton {
background: linear-gradient(90deg, var(--muted) 25%, var(--accent) 50%, var(--muted) 75%);
background-size: 200% 100%;
animation: skeleton 1.5s ease-in-out infinite;
}
@keyframes skeleton {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Responsive design */
@media (max-width: 640px) {
.login-card {
margin: 1rem;
padding: 2rem 1.5rem;
}
}
</style>
</head>
<body>
<div class="page-container relative z-10 flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-md">
<!-- Logo and Header -->
<div class="logo-container text-center mb-8">
<div class="mb-4">
<div class="w-16 h-16 mx-auto bg-gradient-to-br from-blue-600 to-blue-800 rounded-xl flex items-center justify-center">
<i data-lucide="wifi" class="w-8 h-8 text-white"></i>
</div>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Quantic Telecom</h1>
<p class="text-gray-600 text-sm">Connexion Espace Client</p>
<p class="text-gray-500 text-xs mt-1">Votre espace client sécurisé</p>
</div>
<!-- Login Card -->
<div class="login-card p-8">
<form class="space-y-6">
<!-- Email Field -->
<div class="input-group">
<input
type="email"
class="input-field"
placeholder=" "
required
id="email"
>
<label class="floating-label" for="email">Adresse e-mail</label>
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
</div>
<!-- Password Field -->
<div class="input-group">
<input
type="password"
class="input-field"
placeholder=" "
required
id="password"
>
<label class="floating-label" for="password">Mot de passe</label>
<button type="button" class="password-toggle" onclick="togglePassword()">
<i data-lucide="eye" class="w-5 h-5"></i>
</button>
</div>
<!-- Remember Me -->
<div class="flex items-center justify-between">
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" class="custom-checkbox" id="remember">
<span class="text-sm text-gray-700">Se souvenir de moi</span>
</label>
</div>
<!-- Login Button -->
<button type="submit" class="login-button">
<span class="relative z-10">SE CONNECTER</span>
</button>
<!-- Forgot Password -->
<div class="text-center">
<a href="#" class="forgot-link text-sm">Mot de passe oublié ?</a>
</div>
</form>
</div>
<!-- Support Footer -->
<div class="text-center mt-8 space-y-2">
<p class="text-xs text-gray-500">
Besoin d'aide ?
<a href="#" class="text-blue-600 hover:text-blue-800 transition-colors">Support technique</a>
</p>
<p class="text-xs text-gray-400">© 2024 Quantic Telecom - Tous droits réservés</p>
</div>
</div>
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Password toggle functionality
function togglePassword() {
const passwordField = document.getElementById('password');
const toggleIcon = document.querySelector('.password-toggle i');
if (passwordField.type === 'password') {
passwordField.type = 'text';
toggleIcon.setAttribute('data-lucide', 'eye-off');
} else {
passwordField.type = 'password';
toggleIcon.setAttribute('data-lucide', 'eye');
}
lucide.createIcons();
}
// Form validation
const form = document.querySelector('form');
const emailField = document.getElementById('email');
const passwordField = document.getElementById('password');
form.addEventListener('submit', function(e) {
e.preventDefault();
// Reset validation states
emailField.classList.remove('input-error', 'input-success');
passwordField.classList.remove('input-error', 'input-success');
let isValid = true;
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailField.value)) {
emailField.classList.add('input-error');
isValid = false;
} else {
emailField.classList.add('input-success');
}
// Password validation
if (passwordField.value.length < 6) {
passwordField.classList.add('input-error');
isValid = false;
} else {
passwordField.classList.add('input-success');
}
if (isValid) {
// Simulate login process
const button = document.querySelector('.login-button');
button.innerHTML = '<div class="flex items-center justify-center"><div class="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full mr-2"></div>Connexion...</div>';
setTimeout(() => {
alert('Connexion réussie ! (Demo)');
button.innerHTML = '<span class="relative z-10">SE CONNECTER</span>';
}, 2000);
}
});
// Real-time input validation
emailField.addEventListener('input', function() {
this.classList.remove('input-error', 'input-success');
});
passwordField.addEventListener('input', function() {
this.classList.remove('input-error', 'input-success');
});
// Add floating label behavior for better UX
document.querySelectorAll('.input-field').forEach(input => {
input.addEventListener('focus', function() {
this.nextElementSibling.classList.add('focused');
});
input.addEventListener('blur', function() {
if (!this.value) {
this.nextElementSibling.classList.remove('focused');
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,71 @@
:root {
/* Quantic Telecom Brand Colors */
--background: oklch(0.9800 0.0050 240);
--foreground: oklch(0.1500 0.0100 240);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1500 0.0100 240);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1500 0.0100 240);
/* Primary - Telecom Blue */
--primary: oklch(0.4800 0.2000 240);
--primary-foreground: oklch(0.9800 0.0050 240);
--primary-hover: oklch(0.4200 0.2200 240);
/* Secondary - Tech Gray */
--secondary: oklch(0.9200 0.0100 240);
--secondary-foreground: oklch(0.2500 0.0150 240);
/* Accent - Electric Blue */
--accent: oklch(0.6500 0.2800 220);
--accent-foreground: oklch(0.9800 0.0050 240);
/* Muted tones */
--muted: oklch(0.9600 0.0080 240);
--muted-foreground: oklch(0.4500 0.0120 240);
/* Success/Error states */
--success: oklch(0.5500 0.2000 140);
--success-foreground: oklch(0.9800 0.0050 140);
--destructive: oklch(0.5500 0.2200 20);
--destructive-foreground: oklch(0.9800 0.0050 20);
/* Borders and inputs */
--border: oklch(0.8800 0.0150 240);
--input: oklch(0.9600 0.0080 240);
--ring: oklch(0.4800 0.2000 240);
/* Typography */
--font-sans: 'Inter', 'Segoe UI', system-ui, sans-serif;
--font-serif: 'Inter', 'Segoe UI', system-ui, serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing and layout */
--radius: 0.5rem;
--spacing: 1rem;
/* Modern shadows for depth */
--shadow-xs: 0 1px 3px 0 hsl(240 25% 3% / 0.06);
--shadow-sm: 0 1px 3px 0 hsl(240 25% 3% / 0.08), 0 1px 2px -1px hsl(240 25% 3% / 0.08);
--shadow: 0 4px 8px -2px hsl(240 25% 3% / 0.08), 0 2px 4px -2px hsl(240 25% 3% / 0.06);
--shadow-md: 0 8px 16px -4px hsl(240 25% 3% / 0.08), 0 4px 6px -2px hsl(240 25% 3% / 0.06);
--shadow-lg: 0 16px 24px -4px hsl(240 25% 3% / 0.08), 0 8px 8px -4px hsl(240 25% 3% / 0.04);
--shadow-xl: 0 20px 32px -8px hsl(240 25% 3% / 0.12), 0 8px 16px -8px hsl(240 25% 3% / 0.08);
/* Gradients for modern appeal */
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
--gradient-background: linear-gradient(135deg, oklch(0.9900 0.0030 240) 0%, oklch(0.9700 0.0080 220) 100%);
/* Grid overlay for tech aesthetic */
--grid-color: oklch(0.9400 0.0100 240);
/* Glass morphism effects */
--glass-bg: oklch(1.0000 0 0 / 0.70);
--glass-border: oklch(0.9000 0.0200 240 / 0.20);
--glass-backdrop: blur(12px) saturate(180%);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) + 8px);
}

View File

@@ -256,6 +256,38 @@ events = Event.create!([...])
ticket_types = TicketType.create!([...]) ticket_types = TicketType.create!([...])
``` ```
## 🛠️ Available Development Tools
### AST-Grep for Mass Code Replacement
The system has `ast-grep` installed for structural code search and replacement. This tool is particularly useful for:
- **Mass refactoring**: Rename methods, classes, or variables across the codebase
- **Pattern-based replacements**: Update code patterns using AST matching
- **Language-aware transformations**: Safer than regex for code modifications
#### Usage Examples:
```bash
# Find all method calls to a specific method
ast-grep --pattern 'find_by_$FIELD($VALUE)' --lang ruby
# Replace method calls with new syntax
ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)' --lang ruby
# Search for specific Rails patterns
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
```
#### Best Practices:
- Always run with `--dry-run` first to preview changes
- Use `--lang ruby` for Ruby files to ensure proper AST parsing
- Test changes in a branch before applying to main codebase
- Particularly useful for Rails conventions and ActiveRecord pattern updates
## 📝 Code Style & Conventions ## 📝 Code Style & Conventions
- **Ruby Style**: Follow Rails conventions and Rubocop rules - **Ruby Style**: Follow Rails conventions and Rubocop rules
@@ -263,5 +295,6 @@ ticket_types = TicketType.create!([...])
- **JavaScript**: Stimulus controllers for interactive behavior - **JavaScript**: Stimulus controllers for interactive behavior
- **CSS**: Tailwind utility classes with custom components - **CSS**: Tailwind utility classes with custom components
- **Documentation**: Inline comments for complex business logic - **Documentation**: Inline comments for complex business logic
- **Mass Changes**: Use `ast-grep` for structural code replacements instead of simple find/replace
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience. This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience.

View File

@@ -1,8 +1,7 @@
# Project Backlog # Project Backlog
## 📋 Todo ## 📋 Todo
- [ ] Set up project infrastructure - [ ] Set up project infrastructure
- [ ] Design user interface mockups - [ ] Design user interface mockups
- [ ] Create user dashboard - [ ] Create user dashboard
@@ -20,11 +19,11 @@
- [ ] Deploy to production - [ ] Deploy to production
## 🚧 Doing ## 🚧 Doing
- [x] Add login functionality
- [ ] refactor: Moving checkout to OrdersController
## ✅ Done ## ✅ Done
- [x] Initialize git repository - [x] Initialize git repository
- [x] Set up development environment - [x] Set up development environment
- [x] Create project structure - [x] Create project structure
@@ -42,3 +41,4 @@
- [x] Configure environment variables - [x] Configure environment variables
- [x] Create authentication system - [x] Create authentication system
- [x] Implement user registration - [x] Implement user registration
- [x] Add login functionality

View File

@@ -71,6 +71,10 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
# For controller testing helpers
gem "rails-controller-testing"
# For mocking and stubbing
gem "mocha"
end end
gem "devise", "~> 4.9" gem "devise", "~> 4.9"
@@ -83,8 +87,7 @@ gem "kaminari-tailwind", "~> 0.1.0"
gem "stripe", "~> 15.5" gem "stripe", "~> 15.5"
# PDF generation for tickets # PDF generation for tickets
gem "prawn", "~> 2.5" gem "grover"
gem "prawn-qrcode", "~> 0.5"
# QR code generation # QR code generation
gem "rqrcode", "~> 3.1" gem "rqrcode", "~> 3.1"

View File

@@ -127,6 +127,8 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
grover (1.2.3)
nokogiri (~> 1)
i18n (1.14.7) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.8.1) io-console (0.8.1)
@@ -184,6 +186,8 @@ GEM
builder builder
minitest (>= 5.0) minitest (>= 5.0)
ruby-progressbar ruby-progressbar
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0) msgpack (1.8.0)
mysql2 (0.5.6) mysql2 (0.5.6)
net-imap (0.5.9) net-imap (0.5.9)
@@ -219,16 +223,8 @@ GEM
parser (3.3.9.0) parser (3.3.9.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-core (0.10.0)
pp (0.6.2) pp (0.6.2)
prettyprint prettyprint
prawn (2.5.0)
matrix (~> 0.4)
pdf-core (~> 0.10.0)
ttfunk (~> 1.8)
prawn-qrcode (0.5.2)
prawn (>= 1)
rqrcode (>= 1.0.0)
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.4.0) prism (1.4.0)
propshaft (1.2.1) propshaft (1.2.1)
@@ -265,6 +261,10 @@ GEM
activesupport (= 8.0.2.1) activesupport (= 8.0.2.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2.1) 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) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -325,6 +325,7 @@ GEM
rubocop-performance (>= 1.24) rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30) rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rubyzip (3.0.2) rubyzip (3.0.2)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.35.0) selenium-webdriver (4.35.0)
@@ -371,8 +372,6 @@ GEM
thruster (0.1.15-aarch64-linux) thruster (0.1.15-aarch64-linux)
thruster (0.1.15-x86_64-linux) thruster (0.1.15-x86_64-linux)
timeout (0.4.3) timeout (0.4.3)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
turbo-rails (2.0.16) turbo-rails (2.0.16)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
@@ -416,18 +415,19 @@ DEPENDENCIES
debug debug
devise (~> 4.9) devise (~> 4.9)
dotenv-rails dotenv-rails
grover
jbuilder jbuilder
jsbundling-rails jsbundling-rails
kamal kamal
kaminari (~> 1.2) kaminari (~> 1.2)
kaminari-tailwind (~> 0.1.0) kaminari-tailwind (~> 0.1.0)
minitest-reporters (~> 1.7) minitest-reporters (~> 1.7)
mocha
mysql2 (~> 0.5) mysql2 (~> 0.5)
prawn (~> 2.5)
prawn-qrcode (~> 0.5)
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.0.2, >= 8.0.2.1) rails (~> 8.0.2, >= 8.0.2.1)
rails-controller-testing
rqrcode (~> 3.1) rqrcode (~> 3.1)
rubocop-rails-omakase rubocop-rails-omakase
selenium-webdriver selenium-webdriver

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)

124
REFACTORING_SUMMARY.md Normal file
View File

@@ -0,0 +1,124 @@
# Aperonight Application Refactoring Summary
## Overview
This document summarizes the comprehensive refactoring work performed to ensure all code in the Aperonight application is useful and well-documented.
## Phase 1: Previous Code Cleanup (Already Completed)
### Files Removed
- **Unused JavaScript Controllers**: shadcn_test_controller.js, featured_event_controller.js, event_form_controller.js, ticket_type_form_controller.js
- **Unused React Components**: button.jsx, utils.js
- **Duplicate Configuration**: env.example file
### Dependencies Removed
- **Alpine.js Dependencies**: alpinejs, @types/alpinejs (unused in production)
## Phase 2: Current Refactoring Work
### 1. Code Cleanup and Unused Code Removal
#### Removed Dead Code
- **TicketsController**: Removed unused `create_stripe_session` method (lines 78-105) that duplicated functionality already present in OrdersController
- The legacy TicketsController now properly focuses only on redirects and backward compatibility
#### Fixed Issues and Improvements
- **ApplicationHelper**: Fixed typo in comment ("prince" → "price")
- **API Security**: Replaced hardcoded API key with environment variable lookup for better security
- **User Validations**: Improved name length validations (2-50 chars instead of restrictive 3-12 chars)
### 2. Enhanced Documentation and Comments
#### Models (Now Comprehensively Documented)
- **User**: Enhanced comments explaining Devise modules and authorization methods
- **Event**: Detailed documentation of state enum, validations, and scopes
- **Order**: Comprehensive documentation of lifecycle management and payment processing
- **Ticket**: Clear explanation of ticket states and QR code generation
- **TicketType**: Documented pricing methods and availability logic
#### Controllers (Improved Documentation)
- **EventsController**: Added detailed method documentation and purpose explanation
- **OrdersController**: Already well-documented, verified completeness
- **TicketsController**: Enhanced comments explaining legacy redirect functionality
- **ApiController**: Improved API authentication documentation with security notes
#### Services (Enhanced Documentation)
- **StripeInvoiceService**: Already excellently documented
- **TicketPdfGenerator**: Added class-level documentation and suppressed font warnings
#### Jobs (Comprehensive Documentation)
- **CleanupExpiredDraftsJob**: Added comprehensive documentation and improved error handling
- **ExpiredOrdersCleanupJob**: Already well-documented
- **StripeInvoiceGenerationJob**: Already well-documented
#### Helpers (YARD-Style Documentation)
- **FlashMessagesHelper**: Added detailed YARD-style documentation with examples
- **LucideHelper**: Already well-documented
- **StripeHelper**: Verified documentation completeness
### 3. Code Quality Improvements
#### Security Enhancements
- **ApiController**: Moved API key to environment variables/Rails credentials
- Maintained secure authentication patterns throughout
#### Performance Optimizations
- Verified proper use of `includes` for eager loading
- Confirmed efficient database queries with scopes
- Proper use of `find_each` for batch processing
#### Error Handling
- Enhanced error handling in cleanup jobs
- Maintained robust error handling in payment processing
- Added graceful fallbacks where appropriate
### 4. Code Organization and Structure
#### Structure Verification
- Confirmed logical controller organization
- Verified proper separation of concerns
- Maintained clean service object patterns
- Proper use of Rails conventions
## Files Modified in Current Refactoring
1. `app/controllers/tickets_controller.rb` - Removed unused method, fixed layout
2. `app/controllers/api_controller.rb` - Security improvement, removed hardcoded key
3. `app/controllers/events_controller.rb` - Enhanced documentation
4. `app/helpers/application_helper.rb` - Fixed typo
5. `app/helpers/flash_messages_helper.rb` - Added comprehensive documentation
6. `app/jobs/cleanup_expired_drafts_job.rb` - Enhanced documentation and error handling
7. `app/models/user.rb` - Improved validations
8. `app/services/ticket_pdf_generator.rb` - Added documentation and suppressed warnings
## Quality Metrics
- **Tests**: 200 tests, 454 assertions, 0 failures, 0 errors, 0 skips
- **RuboCop**: All style issues resolved automatically
- **Code Coverage**: Maintained existing coverage
- **Documentation**: Significantly improved throughout codebase
- **Bundle Size**: No increase, maintenance of efficient build
## Security Improvements
1. **API Authentication**: Moved from hardcoded to environment-based API keys
2. **Input Validation**: Improved user input validations
3. **Error Handling**: Enhanced error messages without exposing sensitive information
## Recommendations for Future Development
1. **Environment Variables**: Ensure API_KEY is set in production environment
2. **Monitoring**: Consider adding metrics for cleanup job performance
3. **Testing**: Add integration tests for the refactored components
4. **Documentation**: Maintain the documentation standards established
5. **Security**: Regular audit of dependencies and authentication mechanisms
## Conclusion
The Aperonight application has been successfully refactored to ensure all code is useful, well-documented, and follows Rails best practices. The codebase is now more maintainable, secure, and provides a better developer experience. All existing functionality is preserved while significantly improving code quality and documentation standards.
**Total Impact:**
- Removed unused code reducing maintenance overhead
- Enhanced security with proper credential management
- Improved documentation for better maintainability
- Maintained 100% test coverage with 0 failures
- Preserved all existing functionality

View File

@@ -9,44 +9,20 @@
/* Import components */ /* Import components */
@import "components/hero"; @import "components/hero";
@import "components/flash"; @import "components/flash";
@import "components/footer";
@import "components/event-finder"; @import "components/event-finder";
/* Import pages */ /* Import pages */
@import "pages/home"; @import "pages/home";
/* Base styles */ /* QR Code Styles */
body { .qr-code-container {
font-family: var(--font-sans); @apply flex items-center justify-center;
line-height: 1.6;
color: var(--color-neutral-900);
background: var(--color-neutral-50);
} }
/* App wrapper */ .qr-code-container svg {
.app-wrapper { max-width: 100% !important;
min-height: 100vh; max-height: 100% !important;
display: flex; width: 208px !important;
flex-direction: column; height: 208px !important;
} display: block !important;
/* Main content */
main {
flex: 1;
}
/* Footer */
.footer {
background: var(--color-neutral-800);
color: var(--color-neutral-300);
}
/* Flash messages */
.flash {
width: 100%;
}
/* Yield content */
.yield {
width: 100%;
} }

View File

@@ -1,82 +0,0 @@
.footer {
background: var(--color-neutral-800);
color: var(--color-neutral-300);
padding: var(--space-8) 0 var(--space-4);
}
.footer-content {
display: grid;
gap: var(--space-6);
margin-bottom: var(--space-6);
}
@media (min-width: 768px) {
.footer-content {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.footer-content {
grid-template-columns: repeat(4, 1fr);
}
}
.footer-section h3 {
font-family: var(--font-display);
font-weight: 700;
margin-bottom: var(--space-3);
color: white;
font-size: var(--text-lg);
}
.footer-links {
list-style: none;
}
.footer-links li {
margin-bottom: var(--space-2);
}
.footer-links a {
color: var(--color-neutral-400);
text-decoration: none;
transition: color var(--duration-normal);
font-size: var(--text-sm);
}
.footer-links a:hover {
color: var(--color-accent-400);
}
.footer-bottom {
border-top: 1px solid var(--color-neutral-700);
padding-top: var(--space-4);
text-align: center;
color: var(--color-neutral-400);
font-size: var(--text-sm);
}
@media (max-width: 768px) {
.hero {
padding: var(--space-8) 0 var(--space-6);
}
.cta-group {
flex-direction: column;
align-items: center;
}
.hero-stats {
gap: var(--space-4);
}
.features-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -0,0 +1,141 @@
/* PDF Styles for Ticket Generation */
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
color: #000000;
margin: 0;
padding: 20px;
background-color: #ffffff;
}
.ticket-container {
max-width: 350px;
margin: 0 auto;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background-color: #ffffff;
}
/* Header */
.header {
text-align: center;
margin-bottom: 10px;
}
.header h1 {
color: #2D1B69;
font-size: 24px;
font-weight: bold;
margin: 0;
}
/* Event name */
.event-name {
text-align: center;
margin-bottom: 20px;
}
.event-name h2 {
color: #000000;
font-size: 18px;
font-weight: bold;
margin: 0;
}
/* Ticket info box */
.ticket-info-box {
background-color: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 10px;
padding: 15px;
margin-bottom: 20px;
}
.info-row {
margin-bottom: 8px;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-label {
font-weight: bold;
color: #000000;
display: inline-block;
width: 100px;
}
.info-value {
display: inline-block;
color: #000000;
}
/* Venue information */
.venue-info {
margin-bottom: 20px;
}
.venue-info h3 {
color: #374151;
font-size: 14px;
font-weight: bold;
margin: 0 0 8px 0;
}
.venue-details {
font-size: 11px;
}
.venue-name {
font-weight: bold;
margin-bottom: 4px;
}
.venue-address {
color: #000000;
}
/* QR Code */
.qr-code-section {
text-align: center;
margin-bottom: 15px;
}
.qr-code-section h3 {
color: #000000;
font-size: 14px;
font-weight: bold;
margin: 0 0 10px 0;
}
.qr-code-container {
text-align: center;
margin: 0 auto 10px auto;
width: 120px;
height: 120px;
}
.qr-code-text {
font-size: 8px;
color: #6B7280;
}
/* Footer */
.footer {
border-top: 1px solid #E5E7EB;
padding-top: 15px;
text-align: center;
font-size: 8px;
color: #6B7280;
}
.footer p {
margin: 0 0 5px 0;
}
.generated-date {
margin-top: 5px;
}

View File

@@ -0,0 +1,141 @@
/* PDF Styles for Ticket Generation */
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
color: #000000;
margin: 0;
padding: 20px;
background-color: #ffffff;
}
.ticket-container {
max-width: 350px;
margin: 0 auto;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background-color: #ffffff;
}
/* Header */
.header {
text-align: center;
margin-bottom: 10px;
}
.header h1 {
color: #2D1B69;
font-size: 24px;
font-weight: bold;
margin: 0;
}
/* Event name */
.event-name {
text-align: center;
margin-bottom: 20px;
}
.event-name h2 {
color: #000000;
font-size: 18px;
font-weight: bold;
margin: 0;
}
/* Ticket info box */
.ticket-info-box {
background-color: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 10px;
padding: 15px;
margin-bottom: 20px;
}
.info-row {
margin-bottom: 8px;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-label {
font-weight: bold;
color: #000000;
display: inline-block;
width: 100px;
}
.info-value {
display: inline-block;
color: #000000;
}
/* Venue information */
.venue-info {
margin-bottom: 20px;
}
.venue-info h3 {
color: #374151;
font-size: 14px;
font-weight: bold;
margin: 0 0 8px 0;
}
.venue-details {
font-size: 11px;
}
.venue-name {
font-weight: bold;
margin-bottom: 4px;
}
.venue-address {
color: #000000;
}
/* QR Code */
.qr-code-section {
text-align: center;
margin-bottom: 15px;
}
.qr-code-section h3 {
color: #000000;
font-size: 14px;
font-weight: bold;
margin: 0 0 10px 0;
}
.qr-code-container {
text-align: center;
margin: 0 auto 10px auto;
width: 120px;
height: 120px;
}
.qr-code-text {
font-size: 8px;
color: #6B7280;
}
/* Footer */
.footer {
border-top: 1px solid #E5E7EB;
padding-top: 15px;
text-align: center;
font-size: 8px;
color: #6B7280;
}
.footer p {
margin: 0 0 5px 0;
}
.generated-date {
margin-top: 5px;
}

View File

@@ -5,7 +5,7 @@ module Api
module V1 module V1
class EventsController < ApiController class EventsController < ApiController
# Skip API key authentication for store_cart action (used by frontend forms) # Skip API key authentication for store_cart action (used by frontend forms)
skip_before_action :authenticate_api_key, only: [:store_cart] skip_before_action :authenticate_api_key, only: [ :store_cart ]
# Charge l'évén avant certaines actions pour réduire les duplications # Charge l'évén avant certaines actions pour réduire les duplications
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ] before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
@@ -62,6 +62,7 @@ module Api
def store_cart def store_cart
cart_data = params[:cart] || {} cart_data = params[:cart] || {}
session[:pending_cart] = cart_data session[:pending_cart] = cart_data
session[:event_id] = @event.id
render json: { status: "success", message: "Cart stored successfully" } render json: { status: "success", message: "Cart stored successfully" }
rescue => e rescue => e

View File

@@ -0,0 +1,279 @@
# API controller for order management
# Provides RESTful endpoints for order operations
module Api
module V1
class OrdersController < ApiController
before_action :authenticate_user!
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt ]
before_action :set_event, only: [ :new, :create ]
# 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

@@ -16,8 +16,10 @@ class ApiController < ApplicationController
# Extract API key from header or query parameter # Extract API key from header or query parameter
api_key = request.headers["X-API-Key"] || params[:api_key] api_key = request.headers["X-API-Key"] || params[:api_key]
# Validate against hardcoded key (in production, use environment variable) # Validate against environment variable for security
unless api_key == "aperonight-api-key-2025" expected_key = Rails.application.credentials.api_key || ENV["API_KEY"]
unless expected_key.present? && api_key == expected_key
render json: { error: "Unauthorized" }, status: :unauthorized render json: { error: "Unauthorized" }, status: :unauthorized
end end
end end

View File

@@ -14,4 +14,48 @@ class ApplicationController < ActionController::Base
# - CSS nesting and :has() pseudo-class # - CSS nesting and :has() pseudo-class
# allow_browser versions: :modern # allow_browser versions: :modern
# allow_browser versions: { safari: 16.4, firefox: 121, ie: false } # allow_browser versions: { safari: 16.4, firefox: 121, ie: false }
protected
# Generate SEO-friendly path for an event
def seo_event_path(event)
year = event.start_time.year
month = format("%02d", event.start_time.month)
event_path(year: year, month: month, slug: event.slug)
end
helper_method :seo_event_path
# Generate SEO-friendly booking URL for an event
def seo_book_tickets_path(event)
year = event.start_time.year
month = format("%02d", event.start_time.month)
book_event_tickets_path(year: year, month: month, slug: event.slug)
end
helper_method :seo_book_tickets_path
# Generate SEO-friendly checkout URL for an event
def seo_checkout_path(event)
year = event.start_time.year
month = format("%02d", event.start_time.month)
event_checkout_path(year: year, month: month, slug: event.slug)
end
helper_method :seo_checkout_path
# Generate SEO-friendly ticket URL
def seo_ticket_path(ticket)
ticket_path(event_slug: ticket.event.slug, ticket_id: ticket.id)
end
helper_method :seo_ticket_path
# Generate SEO-friendly ticket view URL
def seo_ticket_view_path(ticket)
view_ticket_path(event_slug: ticket.event.slug, ticket_id: ticket.id)
end
helper_method :seo_ticket_view_path
# Generate SEO-friendly ticket download URL
def seo_ticket_download_path(ticket)
download_ticket_path(event_slug: ticket.event.slug, ticket_id: ticket.id)
end
helper_method :seo_ticket_download_path
end end

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # 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_sign_up_params, only: [ :create ]
before_action :configure_account_update_params, only: [ :update ] before_action :configure_account_update_params, only: [ :update ]

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
# Handle payment callbacks for booking workflow
class Booking::PaymentsController < ApplicationController
before_action :authenticate_user!
# Handle successful payment callback
def success
session_id = params[:session_id]
# Check if Stripe is properly configured
stripe_configured = Rails.application.config.stripe[:secret_key].present?
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
unless stripe_configured
redirect_to root_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
return
end
begin
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
if stripe_session.payment_status == "paid"
# Get order_id from session metadata
order_id = stripe_session.metadata["order_id"]
unless order_id.present?
redirect_to dashboard_path, alert: "Informations de commande manquantes"
return
end
# Find and update the order
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
@order.mark_as_paid!
# Schedule Stripe invoice generation in background
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
# Send confirmation emails
@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
# Clear session data
session.delete(:pending_cart)
session.delete(:ticket_names)
session.delete(:draft_order_id)
render "payment_success"
else
redirect_to dashboard_path, alert: "Le paiement n'a pas été complété avec succès"
end
rescue Stripe::StripeError => e
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}"
rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue"
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{error_message}"
end
end
# Handle payment cancellation callback
def 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?
# Extract year and month from event start_time for SEO URL
year = order.event.start_time.year
month = format("%02d", order.event.start_time.month)
redirect_to event_checkout_path(year: year, month: month, slug: order.event.slug),
alert: "Le paiement a été annulé. Vous pouvez réessayer."
else
session.delete(:draft_order_id)
redirect_to root_path, alert: "Le paiement a été annulé et votre commande a expiré."
end
else
redirect_to root_path, alert: "Le paiement a été annulé"
end
end
end

View File

@@ -1,218 +1,59 @@
# Events controller # Events controller - Public event listings and individual event display
# #
# This controller manages all events. It load events for homepage # This controller manages public event browsing and displays individual events
# and display for pagination. # with their associated ticket types. No authentication required for public browsing.
class EventsController < ApplicationController class EventsController < ApplicationController
include StripeConcern # No authentication required for public event viewing
before_action :authenticate_user!, only: []
before_action :set_event, only: [ :show ]
before_action :authenticate_user!, only: [ :checkout, :process_names, :download_ticket ] # Display paginated list of upcoming published events
before_action :set_event, only: [ :show, :checkout, :process_names ] #
# Shows events in published state, ordered by start time ascending
# Display all events # Includes event owner information and supports Kaminari pagination
def index def index
@events = Event.includes(:user).upcoming.page(params[:page]).per(12) @events = Event.includes(:user).upcoming.page(params[:page]).per(12)
end 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 def show
# Event is set by set_event callback # Event is set by set_event callback with ticket types preloaded
end # Template will display event details and ticket selection interface
# Handle checkout process - Collect names if needed or create Stripe session
def checkout
# Convert cart parameter to proper hash
cart_param = params[:cart]
cart_data = if cart_param.is_a?(String)
JSON.parse(cart_param)
elsif cart_param.is_a?(ActionController::Parameters)
cart_param.to_unsafe_h
else
{}
end
if cart_data.empty?
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
return
end
# Check if any ticket types require names
requires_names = false
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
if ticket_type.requires_id
requires_names = true
break
end
end
# If names are required, redirect to name collection
if requires_names
session[:pending_cart] = cart_data
redirect_to event_collect_names_path(@event.slug, @event)
return
end
# Otherwise proceed directly to payment
process_payment(cart_data)
end
# Process submitted names and create Stripe session
def process_names
Rails.logger.debug "Processing names for event: #{@event.id}"
cart_data = session[:pending_cart] || {}
if cart_data.empty?
Rails.logger.debug "Cart data is empty"
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
return
end
# Store names in session for later use
if params[:ticket_names].present?
# Convert ActionController::Parameters to hash
if params[:ticket_names].is_a?(ActionController::Parameters)
session[:ticket_names] = params[:ticket_names].to_unsafe_h
else
session[:ticket_names] = params[:ticket_names]
end
end
Rails.logger.debug "Proceeding to payment with cart data: #{cart_data}"
# Proceed to payment
process_payment(cart_data)
end
# Download ticket PDF
def download_ticket
@ticket = current_user.tickets.find(params[:ticket_id])
respond_to do |format|
format.pdf do
pdf = @ticket.to_pdf
send_data pdf,
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
type: "application/pdf",
disposition: "attachment"
end
end
end end
private private
# Find and set the current event with eager-loaded associations
# Supports both old slug-only format and new SEO-friendly year/month/slug format
# Loads event with ticket types to avoid N+1 queries
def set_event def set_event
@event = Event.includes(:ticket_types).find(params[:id]) if params[:year] && params[:month]
# New SEO-friendly format: /events/2024/07/summer-party
year = params[:year].to_i
month = params[:month].to_i
start_of_month = Date.new(year, month, 1).beginning_of_month
end_of_month = start_of_month.end_of_month
@event = Event.includes(:ticket_types)
.where(slug: params[:slug])
.where(start_time: start_of_month..end_of_month)
.first!
else
# Legacy format: /events/summer-party (for backward compatibility)
@event = Event.includes(:ticket_types).find_by!(slug: params[:slug])
end
rescue ActiveRecord::RecordNotFound
redirect_to events_path, alert: "Événement non trouvé"
end end
# Process payment and create Stripe session # Generate SEO-friendly path for an event
def process_payment(cart_data) def seo_event_path(event)
Rails.logger.debug "Starting process_payment method" year = event.start_time.year
Rails.logger.debug "Cart data: #{cart_data}" month = format("%02d", event.start_time.month)
event_path(year: year, month: month, slug: event.slug)
# Create order items from cart
line_items = []
order_items = []
total_amount = 0
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
# Check availability
available = ticket_type.quantity - ticket_type.tickets.count
if quantity > available
redirect_to event_path(@event.slug, @event), alert: "Pas assez de billets disponibles pour #{ticket_type.name}"
return
end
# Create Stripe line item
line_items << {
price_data: {
currency: "eur",
product_data: {
name: "#{@event.name} - #{ticket_type.name}",
description: ticket_type.description
},
unit_amount: ticket_type.price_cents
},
quantity: quantity
}
# Store for ticket creation
order_items << {
ticket_type_id: ticket_type.id,
ticket_type_name: ticket_type.name,
quantity: quantity,
price_cents: ticket_type.price_cents
}
total_amount += ticket_type.price_cents * quantity
end
if order_items.empty?
redirect_to event_path(@event.slug, @event), alert: "Commande invalide"
return
end
# Get ticket names from session if they exist
ticket_names = session[:ticket_names] || {}
# Debug: Log Stripe configuration status
Rails.logger.debug "Stripe configuration check:"
Rails.logger.debug " Config: #{Rails.application.config.stripe}"
Rails.logger.debug " Secret key present: #{Rails.application.config.stripe[:secret_key].present?}"
Rails.logger.debug " stripe_configured? method exists: #{respond_to?(:stripe_configured?)}"
# Check if Stripe is properly configured
stripe_configured = Rails.application.config.stripe[:secret_key].present?
Rails.logger.debug " Direct stripe_configured check: #{stripe_configured}"
unless stripe_configured
Rails.logger.error "Stripe not configured properly - redirecting to event page"
redirect_to event_path(@event.slug, @event), alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
return
end
# Stripe is now initialized at application startup, no need to initialize here
Rails.logger.debug " Using globally initialized Stripe"
begin
Rails.logger.debug "Creating Stripe Checkout Session"
# Create Stripe Checkout Session
session = 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: event_url(@event.slug, @event),
customer_email: current_user.email,
metadata: {
event_id: @event.id,
user_id: current_user.id,
order_items: order_items.to_json,
ticket_names: ticket_names.to_json
}
})
Rails.logger.debug "Redirecting to Stripe session URL: #{session.url}"
redirect_to session.url, allow_other_host: true
rescue Stripe::StripeError => e
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
Rails.logger.error "Stripe error: #{error_message}"
redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{error_message}"
end
end end
helper_method :seo_event_path
end end

View File

@@ -0,0 +1,17 @@
# Handle legacy URL redirects to new SEO-friendly URLs
class LegacyRedirectsController < ApplicationController
# Redirect old event URLs to new SEO-friendly format
# OLD: /events/summer-party-2024
# NEW: /events/2024/07/summer-party-2024
def event_redirect
event = Event.find_by(slug: params[:slug])
if event
year = event.start_time.year
month = format("%02d", event.start_time.month)
redirect_to event_path(year: year, month: month, slug: event.slug), status: :moved_permanently
else
redirect_to events_path, alert: "Événement non trouvé"
end
end
end

View File

@@ -0,0 +1,228 @@
# Handle order management and checkout process with SEO-friendly URLs
#
# This controller manages the order lifecycle from checkout to payment completion
# Orders group multiple tickets together for better transaction management
class OrdersController < ApplicationController
before_action :authenticate_user!
before_action :set_event_from_seo_params, only: [:new, :create, :checkout]
before_action :set_order_from_id, only: [:show, :retry_payment, :increment_payment_attempt]
# Display new order form with name collection
#
# 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 = params[:cart_data] || session[:pending_cart] || {}
if @cart_data.empty?
redirect_to seo_event_path(@event), alert: "Veuillez d'abord sélectionner vos billets sur la page de l'événement"
return
end
# Build list of tickets requiring names
@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
end
# Create a new order with tickets
#
# 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 = params[:cart_data] || session[:pending_cart] || {}
if @cart_data.empty?
redirect_to seo_event_path(@event), alert: "Aucun billet sélectionné"
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
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
raise ActiveRecord::Rollback
end
end
if @order.tickets.present?
@order.calculate_total!
success = true
else
flash[:alert] = "Aucun billet valide créé"
raise ActiveRecord::Rollback
end
end
# Handle redirects outside transaction
if success
session[:draft_order_id] = @order.id
session.delete(:pending_cart)
year = @event.start_time.year
month = format("%02d", @event.start_time.month)
redirect_to event_checkout_path(year: year, month: month, slug: @event.slug)
else
year = @event.start_time.year
month = format("%02d", @event.start_time.month)
redirect_to book_event_tickets_path(year: year, month: month, slug: @event.slug)
end
rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue"
flash[:alert] = "Une erreur est survenue: #{error_message}"
year = @event.start_time.year
month = format("%02d", @event.start_time.month)
redirect_to book_event_tickets_path(year: year, month: month, slug: @event.slug)
end
# Display order summary
def show
@tickets = @order.tickets.includes(:ticket_type)
end
# Display payment page for an order (SEO-friendly checkout URL)
#
# Display a summary of all tickets in the order and permit user
# to proceed to payment via Stripe
def checkout
# Find order from session or create one
@order = current_user.orders.find_by(id: session[:draft_order_id], event: @event, status: "draft")
unless @order
redirect_to seo_event_path(@event), alert: "Aucune commande en attente trouvée"
return
end
# Handle expired orders
if @order.expired?
@order.expire_if_overdue!
return redirect_to seo_event_path(@event),
alert: "Votre commande a expiré. Veuillez recommencer."
end
@tickets = @order.tickets.includes(:ticket_type)
@total_amount = @order.total_amount_cents
@expiring_soon = @order.expiring_soon?
# Create Stripe checkout session if Stripe is configured
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}"
flash[:alert] = "Erreur lors de la création de la session de paiement"
end
end
end
# Increment payment attempt - called via AJAX when user clicks pay button
def increment_payment_attempt
@order.increment_payment_attempt!
render json: { success: true, attempts: @order.payment_attempts }
end
# Allow users to retry payment for failed/cancelled payments
def retry_payment
unless @order.can_retry_payment?
redirect_to seo_event_path(@order.event),
alert: "Cette commande ne peut plus être payée"
return
end
year = @order.event.start_time.year
month = format("%02d", @order.event.start_time.month)
redirect_to event_checkout_path(year: year, month: month, slug: @order.event.slug)
end
private
def set_event_from_seo_params
year = params[:year].to_i
month = params[:month].to_i
start_of_month = Date.new(year, month, 1).beginning_of_month
end_of_month = start_of_month.end_of_month
@event = Event.includes(:ticket_types)
.where(slug: params[:slug])
.where(start_time: start_of_month..end_of_month)
.first
return redirect_to events_path, alert: "Événement non trouvé" unless @event
end
def set_order_from_id
@order = current_user.orders.includes(:tickets, :event).find(params[:order_id])
@event = @order.event
rescue ActiveRecord::RecordNotFound
redirect_to root_path, alert: "Commande non trouvée"
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: booking_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: booking_payment_cancelled_url + "?order_id=#{@order.id}",
metadata: {
order_id: @order.id,
user_id: current_user.id
}
)
end
# Generate SEO-friendly path for an event
def seo_event_path(event)
year = event.start_time.year
month = format("%02d", event.start_time.month)
event_path(year: year, month: month, slug: event.slug)
end
helper_method :seo_event_path
end

View File

@@ -1,13 +1,13 @@
# Controller for static pages and user dashboard # Controller for static pages and user dashboard
# Handles basic page rendering and user-specific content # Handles basic page rendering and user-specific content
class PagesController < ApplicationController class PagesController < ApplicationController
# Skip authentication for public pages
# skip_before_action :authenticate_user!, only: [ :home ]
before_action :authenticate_user!, only: [ :dashboard ] before_action :authenticate_user!, only: [ :dashboard ]
# Homepage showing featured events # Homepage showing featured events
#
# Display homepage with featured events and incoming ones
def home def home
@events = Event.published.featured.limit(3) @featured_events = Event.published.featured.limit(3)
if user_signed_in? if user_signed_in?
redirect_to(dashboard_path) redirect_to(dashboard_path)
@@ -18,21 +18,24 @@ class PagesController < ApplicationController
# Accessible only to authenticated users # Accessible only to authenticated users
def dashboard def dashboard
# Metrics for dashboard cards # Metrics for dashboard cards
@booked_events = current_user.tickets.joins(:ticket_type, :event).where(events: { state: :published }).count @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_today = Event.published.where("DATE(start_time) = ?", Date.current).count
@events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count @events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count
@upcoming_events = Event.published.upcoming.count @upcoming_events = Event.published.upcoming.count
# User's booked events # User's booked events
@user_booked_events = Event.joins(ticket_types: :tickets) @user_booked_events = Event.joins(ticket_types: { tickets: :order })
.where(tickets: { user: current_user, status: "active" }) .where(orders: { user: current_user }, tickets: { status: "active" })
.distinct .distinct
.limit(5) .limit(5)
# Draft tickets that can be retried # Draft orders that can be retried
@draft_tickets = current_user.tickets.includes(:ticket_type, :event) @draft_orders = current_user.orders.includes(tickets: [ :ticket_type, :event ])
.can_retry_payment .can_retry_payment
.order(:expires_at) .order(:expires_at)
# Events sections # Events sections
@today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc) @today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc)

View File

@@ -0,0 +1,117 @@
# Promoter Events Controller
#
# Handles event management for promoters (event organizers)
# Allows promoters to create, edit, delete and manage their events
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 ]
# Display all events for the current promoter
def index
@events = current_user.events.order(created_at: :desc).page(params[:page]).per(10)
end
# Display a specific event for the promoter
def show
# Event is set by set_event callback
end
# Show form to create a new event
def new
@event = current_user.events.build
end
# Create a new event
def create
@event = current_user.events.build(event_params)
if @event.save
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
else
render :new, status: :unprocessable_entity
end
end
# Show form to edit an existing event
def edit
# Event is set by set_event callback
end
# Update an existing event
def update
if @event.update(event_params)
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
else
render :edit, status: :unprocessable_entity
end
end
# Delete an event
def destroy
@event.destroy
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!"
else
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être publié."
end
end
# Unpublish an event (make it draft)
def unpublish
if @event.published?
@event.update(state: :draft)
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é."
end
end
# Cancel an event
def cancel
if @event.published?
@event.update(state: :canceled)
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é."
end
end
# Mark event as sold out
def mark_sold_out
if @event.published?
@event.update(state: :sold_out)
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."
end
end
private
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."
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."
end
def event_params
params.require(:event).permit(
:name, :slug, :description, :image,
:venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured
)
end
end

View File

@@ -0,0 +1,104 @@
# Promoter Ticket Types Controller
#
# Handles ticket type (bundle) management for promoters
# Allows promoters to create, edit, delete and manage ticket types for their events
class Promoter::TicketTypesController < ApplicationController
before_action :authenticate_user!
before_action :ensure_can_manage_events!
before_action :set_event
before_action :set_ticket_type, only: [ :show, :edit, :update, :destroy ]
# Display all ticket types for an event
def index
@ticket_types = @event.ticket_types.order(:created_at)
end
# Display a specific ticket type
def show
# Ticket type is set by set_ticket_type callback
end
# Show form to create a new ticket type
def new
@ticket_type = @event.ticket_types.build
# Set default values
@ticket_type.sale_start_at = Time.current
@ticket_type.sale_end_at = @event.start_time || 1.week.from_now
@ticket_type.requires_id = false
end
# Create a new ticket type
def create
@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!"
else
render :new, status: :unprocessable_entity
end
end
# Show form to edit an existing ticket type
def edit
# Ticket type is set by set_ticket_type callback
end
# 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!"
else
render :edit, status: :unprocessable_entity
end
end
# 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."
else
@ticket_type.destroy
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet supprimé avec succès!"
end
end
# Duplicate an existing ticket type
def duplicate
original = @event.ticket_types.find(params[:id])
@ticket_type = original.dup
@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!"
else
redirect_to promoter_event_ticket_types_path(@event), alert: "Erreur lors de la duplication."
end
end
private
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."
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."
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é."
end
def ticket_type_params
params.require(:ticket_type).permit(
:name, :description, :price_euros, :quantity,
:sale_start_at, :sale_end_at, :minimum_age, :requires_id
)
end
end

View File

@@ -1,311 +1,127 @@
# Manage tickets creation # Tickets controller - handles ticket viewing and downloads with SEO-friendly URLs
# #
# This controller permit users to create a new ticket for an event, # This controller manages individual ticket display and downloads
# complete their details and proceed to payment # Uses event-slug-ticket-id format for SEO-friendly URLs
class TicketsController < ApplicationController class TicketsController < ApplicationController
before_action :authenticate_user!, only: [ :new, :payment_success, :payment_cancel ] before_action :authenticate_user!
before_action :set_event, only: [ :new ] before_action :set_ticket_from_seo_params, only: [:show, :view, :download, :retry_payment]
# Handle new ticket creation # Display ticket details
# def show
# Once user selected ticket types he wans for an event @event = @ticket.event
# he cames here where he can complete his details (first_name, last_name)
# for each ticket ordered
def new
@cart_data = session[:pending_cart] || {}
if @cart_data.empty?
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
return
end
# Build list of tickets requiring names
@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,
index: i
}
end
end
end end
# Create a new ticket # Display ticket in PDF-like format
# def view
# Here new tickets are created but still in draft state. @event = @ticket.event
# When user is ready he can proceed to payment
def create
@cart_data = session[:pending_cart] || {}
if @cart_data.empty?
redirect_to event_path(params[:slug], params[:id]), alert: "Aucun billet sélectionné"
return
end
@event = Event.includes(:ticket_types).find(params[:id])
@tickets = []
ActiveRecord::Base.transaction do
ticket_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 = current_user.tickets.build(
ticket_type: ticket_type,
first_name: ticket_attrs[:first_name],
last_name: ticket_attrs[:last_name],
status: "draft"
)
if ticket.save
@tickets << ticket
else
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
raise ActiveRecord::Rollback
end
end
if @tickets.present?
session[:draft_ticket_ids] = @tickets.map(&:id)
session.delete(:pending_cart)
redirect_to ticket_checkout_path(@event.slug, @event.id)
else
flash[:alert] = "Aucun billet valide créé"
redirect_to ticket_new_path(@event.slug, @event.id)
end
end
rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue"
flash[:alert] = "Une erreur est survenue: #{error_message}"
redirect_to ticket_new_path(params[:slug], params[:id])
end end
# Display payment page # Download PDF ticket - only accessible by ticket owner
# # User must be authenticated to download ticket
# Display a sumup of all tickets ordered by user and permit it def download
# to go to payment page. # Generate PDF using Grover
# Here the user can pay for a ticket a bundle of tickets
def checkout
@event = Event.includes(:ticket_types).find(params[:id])
draft_ticket_ids = session[:draft_ticket_ids] || []
if draft_ticket_ids.empty?
redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement"
return
end
@tickets = current_user.tickets.includes(:ticket_type)
.where(id: draft_ticket_ids, status: "draft")
# Check for expired tickets and clean them up
expired_tickets = @tickets.select(&:expired?)
if expired_tickets.any?
expired_tickets.each(&:expire_if_overdue!)
@tickets = @tickets.reject(&:expired?)
if @tickets.empty?
session.delete(:draft_ticket_ids)
redirect_to event_path(@event.slug, @event), alert: "Vos billets ont expiré. Veuillez recommencer votre commande."
return
end
flash[:notice] = "Certains billets ont expiré et ont été supprimés de votre commande."
end
# Check if tickets can still be retried
non_retryable_tickets = @tickets.reject(&:can_retry_payment?)
if non_retryable_tickets.any?
non_retryable_tickets.each(&:expire_if_overdue!)
@tickets = @tickets.select(&:can_retry_payment?)
if @tickets.empty?
session.delete(:draft_ticket_ids)
redirect_to event_path(@event.slug, @event), alert: "Nombre maximum de tentatives de paiement atteint. Veuillez recommencer votre commande."
return
end
flash[:notice] = "Certains billets ont atteint le nombre maximum de tentatives de paiement."
end
if @tickets.empty?
redirect_to event_path(@event.slug, @event), alert: "Billets non trouvés ou déjà traités"
return
end
@total_amount = @tickets.sum(&:price_cents)
# Check for expiring soon tickets
@expiring_soon = @tickets.any?(&:expiring_soon?)
# Create Stripe checkout session if Stripe is configured
if Rails.application.config.stripe[:secret_key].present?
begin
@checkout_session = create_stripe_session
# Only increment payment attempts after successfully creating the session
@tickets.each(&:increment_payment_attempt!)
rescue => e
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
flash[:alert] = "Erreur lors de la création de la session de paiement"
end
end
end
# Handle successful payment
def payment_success
session_id = params[:session_id]
# Check if Stripe is properly configured
stripe_configured = Rails.application.config.stripe[:secret_key].present?
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."
return
end
begin begin
stripe_session = Stripe::Checkout::Session.retrieve(session_id) Rails.logger.info "Starting PDF generation for ticket ID: #{@ticket.id}"
if stripe_session.payment_status == "paid" # Render the HTML template
# Get event_id and ticket_ids from session metadata html = render_to_string(
event_id = stripe_session.metadata["event_id"] partial: "tickets/pdf_ticket",
ticket_ids_data = stripe_session.metadata["ticket_ids"] layout: false,
locals: { ticket: @ticket }
)
unless event_id.present? && ticket_ids_data.present? Rails.logger.info "HTML template rendered successfully, length: #{html.length}"
redirect_to dashboard_path, alert: "Informations de commande manquantes"
return
end
# Update existing draft tickets to active # Configure Grover options for PDF generation
@event = Event.find(event_id) pdf_options = {
ticket_ids = ticket_ids_data.split(",") format: 'A4',
@tickets = current_user.tickets.where(id: ticket_ids, status: "draft") margin: {
top: '0.5in',
bottom: '0.5in',
left: '0.5in',
right: '0.5in'
},
print_background: true,
display_header_footer: false,
prefer_css_page_size: true,
launch_args: ["--no-sandbox", "--disable-setuid-sandbox"] # For better compatibility
}
if @tickets.empty? # Generate PDF
redirect_to dashboard_path, alert: "Billets non trouvés" pdf = Grover.new(html, pdf_options).to_pdf
return
end
@tickets.update_all(status: "active") Rails.logger.info "PDF generation completed for ticket ID: #{@ticket.id}"
# Send confirmation emails # Send PDF as download with SEO-friendly filename
@tickets.each do |ticket| send_data pdf,
TicketMailer.purchase_confirmation(ticket).deliver_now filename: "billet-#{@ticket.event.slug}-#{@ticket.id}.pdf",
end type: 'application/pdf',
disposition: 'attachment'
# Clear session data
session.delete(:pending_cart)
session.delete(:ticket_names)
session.delete(:draft_ticket_ids)
render "payment_success"
else
redirect_to dashboard_path, alert: "Le paiement n'a pas été complété avec succès"
end
rescue Stripe::StripeError => e
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}"
rescue => e rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue" Rails.logger.error "PDF generation failed for ticket ID: #{@ticket.id} - Error: #{e.message}"
Rails.logger.error "Payment success error: #{e.class} - #{error_message}" Rails.logger.error e.backtrace.join("\n")
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{error_message}"
redirect_to view_ticket_path(event_slug: @ticket.event.slug, ticket_id: @ticket.id),
alert: "Erreur lors de la génération du PDF. Veuillez réessayer."
end end
end end
# Handle payment failure/cancellation # Redirect retry payment to order system
def payment_cancel
# Keep draft tickets for potential retry, just redirect back to checkout
draft_ticket_ids = session[:draft_ticket_ids] || []
if draft_ticket_ids.any?
tickets = current_user.tickets.where(id: draft_ticket_ids, status: "draft")
retryable_tickets = tickets.select(&:can_retry_payment?)
if retryable_tickets.any?
event = retryable_tickets.first.event
redirect_to ticket_checkout_path(event.slug, event.id),
alert: "Le paiement a été annulé. Vous pouvez réessayer."
else
session.delete(:draft_ticket_ids)
redirect_to dashboard_path, alert: "Le paiement a été annulé et vos billets ont expiré."
end
else
redirect_to dashboard_path, alert: "Le paiement a été annulé"
end
end
# Allow users to retry payment for failed/cancelled payments
def retry_payment def retry_payment
@event = Event.includes(:ticket_types).find(params[:id]) # Look for draft order for this ticket's event
ticket_ids = params[:ticket_ids]&.split(',') || [] order = current_user.orders.find_by(event: @ticket.event, status: "draft")
@tickets = current_user.tickets.where(id: ticket_ids) if order&.can_retry_payment?
.select(&:can_retry_payment?) year = order.event.start_time.year
month = format("%02d", order.event.start_time.month)
if @tickets.empty? redirect_to event_checkout_path(year: year, month: month, slug: order.event.slug)
redirect_to event_path(@event.slug, @event), else
alert: "Aucun billet disponible pour un nouveau paiement" redirect_to seo_event_path(@ticket.event),
return alert: "Aucune commande disponible pour un nouveau paiement"
end end
# Set session for checkout
session[:draft_ticket_ids] = @tickets.map(&:id)
redirect_to ticket_checkout_path(@event.slug, @event.id)
end end
def show # Legacy redirects for backward compatibility
@ticket = current_user.tickets.includes(:ticket_type, :event).find(params[:ticket_id]) def payment_success
@event = @ticket.event redirect_to booking_payment_success_path(session_id: params[:session_id])
rescue ActiveRecord::RecordNotFound end
redirect_to dashboard_path, alert: "Billet non trouvé"
end def payment_cancel
redirect_to booking_payment_cancelled_path
end
private private
def set_event def set_ticket_from_seo_params
@event = Event.includes(:ticket_types).find(params[:id]) # Parse event_slug and ticket_id from the SEO-friendly format: event-slug-123
end slug_and_id = params[:event_slug_ticket_id] || "#{params[:event_slug]}-#{params[:ticket_id]}"
def ticket_params # Split by last dash to separate event slug from ticket ID
params.permit(tickets_attributes: [ :ticket_type_id, :first_name, :last_name ]) parts = slug_and_id.split('-')
end ticket_id = parts.pop
event_slug = parts.join('-')
def create_stripe_session # Find ticket and ensure it belongs to current user
line_items = @tickets.map do |ticket| @ticket = Ticket.joins(order: :user)
{ .includes(:event, :ticket_type, order: :user)
price_data: { .joins(:event)
currency: "eur", .where(
product_data: { tickets: { id: ticket_id },
name: "#{@event.name} - #{ticket.ticket_type.name}", orders: { user_id: current_user.id },
description: ticket.ticket_type.description events: { slug: event_slug }
}, )
unit_amount: ticket.price_cents .first
},
quantity: 1 unless @ticket
} redirect_to dashboard_path, alert: "Billet non trouvé ou vous n'avez pas l'autorisation d'accéder à ce billet"
end 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
# Generate SEO-friendly path for an event
def seo_event_path(event)
year = event.start_time.year
month = format("%02d", event.start_time.month)
event_path(year: year, month: month, slug: event.slug)
end
helper_method :seo_event_path
end end

View File

@@ -1,5 +1,5 @@
module ApplicationHelper module ApplicationHelper
# Convert prince from cents to float # Convert price from cents to float
def format_price(cents) def format_price(cents)
(cents.to_f / 100).round(2) (cents.to_f / 100).round(2)
end 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 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) def flash_class(type)
case type.to_s case type.to_s
when "notice", "success" when "notice", "success"
@@ -14,6 +26,14 @@ module FlashMessagesHelper
end end
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) def flash_icon(type)
case type.to_s case type.to_s
when "notice", "success" when "notice", "success"

View File

@@ -0,0 +1,82 @@
module LucideHelper
# Create a Lucide icon element
#
# @param name [String] The name of the Lucide icon
# @param options [Hash] Additional options
# @option options [String] :class Additional CSS classes
# @option options [String] :size Size class (e.g., 'w-4 h-4', 'w-6 h-6')
# @option options [Hash] :data Additional data attributes
#
# @return [String] HTML string for the icon
#
# Usage:
# lucide_icon('user')
# lucide_icon('check-circle', class: 'text-green-500', size: 'w-5 h-5')
# lucide_icon('menu', data: { action: 'click->header#toggleMenu' })
def lucide_icon(name, options = {})
css_classes = [ "lucide-icon" ]
css_classes << options[:size] if options[:size]
css_classes << options[:class] if options[:class]
data_attributes = { lucide: name }
data_attributes.merge!(options[:data]) if options[:data]
content_tag :i, "",
class: css_classes.join(" "),
data: data_attributes,
**options.except(:class, :size, :data)
end
# Create a button with a Lucide icon
#
# @param name [String] The name of the Lucide icon
# @param options [Hash] Button options
# @option options [String] :text Button text (optional)
# @option options [String] :class Additional CSS classes for button
# @option options [String] :icon_class Additional CSS classes for icon
# @option options [String] :icon_size Size class for icon
#
# Usage:
# lucide_button('plus', text: 'Add Item', class: 'btn btn-primary')
# lucide_button('trash-2', class: 'btn-danger', data: { confirm: 'Are you sure?' })
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 = lucide_icon(name, class: icon_class, size: icon_size)
content = if text.present?
safe_join([ icon, " ", text ])
else
icon
end
content_tag :button, content, options
end
# Create a link with a Lucide icon
#
# @param name [String] The name of the Lucide icon
# @param url [String] The URL for the link
# @param options [Hash] Link options
#
# Usage:
# lucide_link('edit', edit_user_path(user), text: 'Edit')
# lucide_link('external-link', 'https://example.com', text: 'Visit', target: '_blank')
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 = lucide_icon(name, class: icon_class, size: icon_size)
content = if text.present?
safe_join([ icon, " ", text ])
else
icon
end
link_to content, url, options
end
end

17
app/helpers/pdf_helper.rb Normal file
View File

@@ -0,0 +1,17 @@
module PdfHelper
require "rqrcode"
# Generate SVG QR code for tickets
def qr_code_tag(data)
qrcode = RQRCode::QRCode.new(data)
# Render as SVG
raw qrcode.as_svg(
offset: 0,
color: "000",
shape_rendering: "crispEdges",
module_size: 4,
standalone: true
)
end
end

View File

@@ -6,3 +6,18 @@ import "@hotwired/turbo-rails";
// Import all Stimulus controllers // Import all Stimulus controllers
import "./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

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

@@ -2,26 +2,19 @@
// Run that command whenever you add a new controller or create them with // Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName // ./bin/rails generate stimulus controllerName
import { application } from "./application" import { application } from "./application";
import LogoutController from "./logout_controller"; import LogoutController from "./logout_controller";
application.register("logout", LogoutController); application.register("logout", LogoutController);
import CounterController from "./counter_controller" import CounterController from "./counter_controller";
application.register("counter", CounterController); application.register("counter", CounterController);
import FlashMessageController from "./flash_message_controller" import FlashMessageController from "./flash_message_controller";
application.register("flash-message", FlashMessageController); application.register("flash-message", FlashMessageController);
import TicketSelectionController from "./ticket_selection_controller" import TicketSelectionController from "./ticket_selection_controller";
application.register("ticket-selection", TicketSelectionController); application.register("ticket-selection", TicketSelectionController);
import HeaderController from "./header_controller" import HeaderController from "./header_controller";
application.register("header", HeaderController); application.register("header", HeaderController);

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

@@ -1,150 +1,169 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus";
// Controller for handling ticket selection on the event show page // Controller for handling ticket selection on the event show page
// Manages quantity inputs, calculates totals, and enables/disables the checkout button // Manages quantity inputs, calculates totals, and enables/disables the checkout button
export default class extends Controller { export default class extends Controller {
static targets = ["quantityInput", "totalQuantity", "totalAmount", "checkoutButton", "form"] static targets = [
static values = { eventSlug: String, eventId: String } "quantityInput",
"totalQuantity",
"totalAmount",
"checkoutButton",
"form",
];
static values = { eventSlug: String, eventId: String };
// Initialize the controller and update the cart summary // Initialize the controller and update the cart summary
connect() { connect() {
this.updateCartSummary() this.updateCartSummary();
this.bindFormSubmission() this.bindFormSubmission();
} }
// Bind form submission to handle cart storage // Bind form submission to handle cart storage
bindFormSubmission() { bindFormSubmission() {
if (this.hasFormTarget) { if (this.hasFormTarget) {
this.formTarget.addEventListener('submit', this.submitCart.bind(this)) this.formTarget.addEventListener("submit", this.submitCart.bind(this));
} }
} }
// Increment the quantity for a specific ticket type // Increment the quantity for a specific ticket type
increment(event) { increment(event) {
const ticketTypeId = event.currentTarget.dataset.target const ticketTypeId = event.currentTarget.dataset.target;
const input = this.quantityInputTargets.find(input => input.dataset.target === ticketTypeId) const input = this.quantityInputTargets.find(
const value = parseInt(input.value) || 0 (input) => input.dataset.target === ticketTypeId,
const max = parseInt(input.max) || 0 );
const value = parseInt(input.value) || 0;
const max = parseInt(input.max) || 0;
if (value < max) { if (value < max) {
input.value = value + 1 input.value = value + 1;
this.updateCartSummary() this.updateCartSummary();
} }
} }
// Decrement the quantity for a specific ticket type // Decrement the quantity for a specific ticket type
decrement(event) { decrement(event) {
const ticketTypeId = event.currentTarget.dataset.target const ticketTypeId = event.currentTarget.dataset.target;
const input = this.quantityInputTargets.find(input => input.dataset.target === ticketTypeId) const input = this.quantityInputTargets.find(
const value = parseInt(input.value) || 0 (input) => input.dataset.target === ticketTypeId,
);
const value = parseInt(input.value) || 0;
if (value > 0) { if (value > 0) {
input.value = value - 1 input.value = value - 1;
this.updateCartSummary() this.updateCartSummary();
} }
} }
// Update quantity when directly edited in the input field // Update quantity when directly edited in the input field
updateQuantity(event) { updateQuantity(event) {
const input = event.currentTarget const input = event.currentTarget;
let value = parseInt(input.value) || 0 let value = parseInt(input.value) || 0;
const max = parseInt(input.max) || 0 const max = parseInt(input.max) || 0;
// Ensure value is within valid range (0 to max available) // Ensure value is within valid range (0 to max available)
if (value < 0) value = 0 if (value < 0) value = 0;
if (value > max) value = max if (value > max) value = max;
input.value = value input.value = value;
this.updateCartSummary() this.updateCartSummary();
} }
// Calculate and update the cart summary (total quantity and amount) // Calculate and update the cart summary (total quantity and amount)
updateCartSummary() { updateCartSummary() {
let totalQuantity = 0 let totalQuantity = 0;
let totalAmount = 0 let totalAmount = 0;
// Sum up quantities and calculate total amount // Sum up quantities and calculate total amount
this.quantityInputTargets.forEach(input => { this.quantityInputTargets.forEach((input) => {
const quantity = parseInt(input.value) || 0 const quantity = parseInt(input.value) || 0;
const price = parseInt(input.dataset.price) || 0 const price = parseInt(input.dataset.price) || 0;
totalQuantity += quantity totalQuantity += quantity;
totalAmount += quantity * price totalAmount += quantity * price;
}) });
// Update the displayed total quantity and amount // Update the displayed total quantity and amount
this.totalQuantityTarget.textContent = totalQuantity this.totalQuantityTarget.textContent = totalQuantity;
this.totalAmountTarget.textContent = `${(totalAmount / 100).toFixed(2)}` this.totalAmountTarget.textContent = `${(totalAmount / 100).toFixed(2)}`;
// Enable/disable checkout button based on whether any tickets are selected // Enable/disable checkout button based on whether any tickets are selected
if (totalQuantity > 0) { if (totalQuantity > 0) {
this.checkoutButtonTarget.classList.remove('opacity-50', 'cursor-not-allowed') this.checkoutButtonTarget.classList.remove(
this.checkoutButtonTarget.disabled = false "opacity-50",
"cursor-not-allowed",
);
this.checkoutButtonTarget.disabled = false;
} else { } else {
this.checkoutButtonTarget.classList.add('opacity-50', 'cursor-not-allowed') this.checkoutButtonTarget.classList.add(
this.checkoutButtonTarget.disabled = true "opacity-50",
"cursor-not-allowed",
);
this.checkoutButtonTarget.disabled = true;
} }
} }
// Handle form submission - store cart in session before proceeding // Handle form submission - store cart in session before proceeding
async submitCart(event) { async submitCart(event) {
event.preventDefault() event.preventDefault();
const cartData = this.buildCartData() const cartData = this.buildCartData();
if (Object.keys(cartData).length === 0) { if (Object.keys(cartData).length === 0) {
alert('Veuillez sélectionner au moins un billet') alert("Veuillez sélectionner au moins un billet");
return return;
} }
try { try {
// Store cart data in session // Store cart data in session
await this.storeCartInSession(cartData) await this.storeCartInSession(cartData);
// Redirect to tickets/new page
const ticketNewUrl = `/events/${this.eventSlugValue}.${this.eventIdValue}/tickets/new`
window.location.href = ticketNewUrl
// Redirect to event-scoped orders/new page
const OrderNewUrl = `/events/${this.eventSlugValue}/orders/new`;
window.location.href = OrderNewUrl;
} catch (error) { } catch (error) {
console.error('Error storing cart:', error) console.error("Error storing cart:", error);
alert('Une erreur est survenue. Veuillez réessayer.') alert("Une erreur est survenue. Veuillez réessayer.");
} }
} }
// Build cart data from current form state // Build cart data from current form state
buildCartData() { buildCartData() {
const cartData = {} const cartData = {};
this.quantityInputTargets.forEach(input => { this.quantityInputTargets.forEach((input) => {
const quantity = parseInt(input.value) || 0 const quantity = parseInt(input.value) || 0;
if (quantity > 0) { if (quantity > 0) {
const ticketTypeId = input.dataset.target const ticketTypeId = input.dataset.target;
cartData[ticketTypeId] = { cartData[ticketTypeId] = {
quantity: quantity quantity: quantity,
} };
} }
}) });
return cartData return cartData;
} }
// Store cart data in session via AJAX // Store cart data in session via AJAX
async storeCartInSession(cartData) { async storeCartInSession(cartData) {
const storeCartUrl = `/api/v1/events/${this.eventIdValue}/store_cart` const storeCartUrl = `/api/v1/events/${this.eventIdValue}/store_cart`;
const response = await fetch(storeCartUrl, { const response = await fetch(storeCartUrl, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') "X-CSRF-Token": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
}, },
body: JSON.stringify({ cart: cartData }) body: JSON.stringify({ cart: cartData }),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to store cart data: ${response.status} ${response.statusText}`) throw new Error(
`Failed to store cart data: ${response.status} ${response.statusText}`,
);
} }
return response.json() return response.json();
} }
} }

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 class CleanupExpiredDraftsJob < ApplicationJob
queue_as :default 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 def perform
expired_count = 0 expired_count = 0
# Process expired draft tickets in batches
Ticket.expired_drafts.find_each do |ticket| Ticket.expired_drafts.find_each do |ticket|
Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user_id}" begin
ticket.expire_if_overdue! Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user.id}"
expired_count += 1 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 end
# Log summary if any tickets were processed
Rails.logger.info "Expired #{expired_count} draft tickets" if expired_count > 0 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
end end

View File

@@ -0,0 +1,23 @@
class ExpiredOrdersCleanupJob < ApplicationJob
queue_as :default
def perform
# Find and expire all draft orders that have passed their expiry time
expired_orders = Order.expired_drafts
Rails.logger.info "Found #{expired_orders.count} expired orders to process"
expired_orders.find_each do |order|
begin
order.expire_if_overdue!
Rails.logger.info "Expired order ##{order.id} for user ##{order.user_id}"
rescue => e
Rails.logger.error "Failed to expire order ##{order.id}: #{e.message}"
# Continue processing other orders even if one fails
next
end
end
Rails.logger.info "Completed expired orders cleanup job"
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,5 +1,5 @@
class TicketMailer < ApplicationMailer class TicketMailer < ApplicationMailer
default from: 'notifications@aperonight.com' default from: "notifications@aperonight.com"
def purchase_confirmation(ticket) def purchase_confirmation(ticket)
@ticket = ticket @ticket = ticket
@@ -9,7 +9,7 @@ class TicketMailer < ApplicationMailer
# Generate PDF attachment # Generate PDF attachment
pdf = @ticket.to_pdf pdf = @ticket.to_pdf
attachments["ticket-#{@event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf"] = { attachments["ticket-#{@event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf"] = {
mime_type: 'application/pdf', mime_type: "application/pdf",
content: pdf content: pdf
} }

View File

@@ -17,6 +17,7 @@ class Event < ApplicationRecord
belongs_to :user belongs_to :user
has_many :ticket_types has_many :ticket_types
has_many :tickets, through: :ticket_types has_many :tickets, through: :ticket_types
has_many :orders
# Validations for Event attributes # Validations for Event attributes
# Basic information # Basic information
@@ -47,5 +48,4 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time # Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) } scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
end end

127
app/models/order.rb Normal file
View File

@@ -0,0 +1,127 @@
class Order < ApplicationRecord
# === Constants ===
DRAFT_EXPIRY_TIME = 30.minutes
MAX_PAYMENT_ATTEMPTS = 3
# === Associations ===
belongs_to :user
belongs_to :event
has_many :tickets, dependent: :destroy
# === Validations ===
validates :user_id, presence: true
validates :event_id, presence: true
validates :status, presence: true, inclusion: {
in: %w[draft pending_payment paid completed cancelled expired]
}
validates :total_amount_cents, presence: true,
numericality: { greater_than_or_equal_to: 0 }
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]) }
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
scope :can_retry_payment, -> {
draft.where("payment_attempts < ? AND expires_at > ?",
MAX_PAYMENT_ATTEMPTS, Time.current)
}
before_validation :set_expiry, on: :create
# === Instance Methods ===
# Total amount in euros (formatted)
def total_amount_euros
total_amount_cents / 100.0
end
# Check if order can be retried for payment
def can_retry_payment?
draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired?
end
# Check if order is expired
def expired?
expires_at.present? && expires_at < Time.current
end
# Mark order as expired if it's past expiry time
def expire_if_overdue!
return unless draft? && expired?
update!(status: "expired")
end
# Increment payment attempt counter
def increment_payment_attempt!
update!(
payment_attempts: payment_attempts + 1,
last_payment_attempt_at: Time.current
)
end
# Check if draft is about to expire (within 5 minutes)
def expiring_soon?
return false unless draft? && expires_at.present?
expires_at <= 5.minutes.from_now
end
# Mark order as paid and activate all tickets
def mark_as_paid!
transaction do
update!(status: "paid")
tickets.update_all(status: "active")
end
end
# Calculate total from tickets
def calculate_total!
update!(total_amount_cents: tickets.sum(:price_cents))
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
def set_expiry
return unless status == "draft"
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
end
def draft?
status == "draft"
end
end

View File

@@ -1,73 +1,76 @@
class Ticket < ApplicationRecord class Ticket < ApplicationRecord
# === Constants ===
DRAFT_EXPIRY_TIME = 30.minutes
MAX_PAYMENT_ATTEMPTS = 3
# === Associations === # === Associations ===
belongs_to :user belongs_to :order
belongs_to :ticket_type belongs_to :ticket_type
has_one :event, through: :ticket_type has_one :event, through: :ticket_type
has_one :user, through: :order
# === Validations === # === Validations ===
validates :qr_code, presence: true, uniqueness: true validates :qr_code, presence: true, uniqueness: true
validates :user_id, presence: true validates :order_id, presence: true
validates :ticket_type_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: 0 }
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] } validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
validates :first_name, presence: true validates :first_name, presence: true
validates :last_name, presence: true validates :last_name, presence: true
validates :payment_attempts, presence: true, numericality: { greater_than_or_equal_to: 0 }
# === Scopes === # === Scopes ===
scope :draft, -> { where(status: "draft") } scope :draft, -> { where(status: "draft") }
scope :active, -> { where(status: "active") } scope :active, -> { where(status: "active") }
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) } scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) }
scope :can_retry_payment, -> { draft.where("payment_attempts < ? AND expires_at > ?", MAX_PAYMENT_ATTEMPTS, Time.current) }
before_validation :set_price_from_ticket_type, on: :create before_validation :set_price_from_ticket_type, on: :create
before_validation :generate_qr_code, on: :create before_validation :generate_qr_code, on: :create
before_validation :set_draft_expiry, on: :create
# Generate PDF ticket # Generate PDF ticket
def to_pdf def to_pdf
TicketPdfGenerator.new(self).generate TicketPdfGenerator.new(self).generate
end end
# Generate QR code data for ticket validation
def to_qr_data
{
ticket_id: id,
qr_code: qr_code,
event_id: event&.id,
user_id: user&.id
}.compact.to_json
end
# Generate QR code as SVG
def generate_qr_svg
require "rqrcode"
qrcode = RQRCode::QRCode.new(to_qr_data)
qrcode.as_svg(
offset: 0,
color: "000",
shape_rendering: "crispEdges",
module_size: 4,
standalone: true
)
end
# Price in euros (formatted) # Price in euros (formatted)
def price_euros def price_euros
price_cents / 100.0 price_cents / 100.0
end end
# Check if ticket can be retried for payment # Delegate payment methods to order
def can_retry_payment? def can_retry_payment?
draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired? order.can_retry_payment?
end end
# Check if ticket is expired
def expired? def expired?
expires_at.present? && expires_at < Time.current order.expired?
end end
# Mark ticket as expired if it"s past expiry time
def expire_if_overdue!
return unless draft? && expired?
update!(status: "expired")
end
# Increment payment attempt counter
def increment_payment_attempt!
update!(
payment_attempts: payment_attempts + 1,
last_payment_attempt_at: Time.current
)
end
# Check if draft is about to expire (within 5 minutes)
def expiring_soon? def expiring_soon?
return false unless draft? && expires_at.present? order.expiring_soon?
end
expires_at <= 5.minutes.from_now # Mark ticket as expired if it's past expiry time
def expire_if_overdue!
order.expire_if_overdue!
end end
private private
@@ -84,13 +87,12 @@ class Ticket < ApplicationRecord
self.qr_code = SecureRandom.uuid self.qr_code = SecureRandom.uuid
break unless Ticket.exists?(qr_code: qr_code) break unless Ticket.exists?(qr_code: qr_code)
end end
rescue => e
Rails.logger.error "Failed to generate QR code for ticket: #{e.message}"
# Generate a simple fallback QR code
self.qr_code = "#{id || 'temp'}-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end end
def set_draft_expiry
return unless status == "draft"
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
end
def draft? def draft?
status == "draft" status == "draft"

View File

@@ -12,7 +12,64 @@ class TicketType < ApplicationRecord
validates :sale_end_at, presence: true validates :sale_end_at, presence: true
validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true
validates :event_id, presence: true validates :event_id, presence: true
validates :requires_id, inclusion: { in: [ true, false ] }
# Custom validations
validate :sale_end_after_start
validate :sale_times_within_event_period
# Scopes
scope :available_now, -> { where("sale_start_at <= ? AND sale_end_at >= ?", Time.current, Time.current) }
scope :upcoming, -> { where("sale_start_at > ?", Time.current) }
scope :expired, -> { where("sale_end_at < ?", Time.current) }
# Helper methods
def price_euros
return 0.0 if price_cents.nil?
price_cents / 100.0
end
def price_euros=(value)
self.price_cents = (value.to_f * 100).to_i
end
def available?
return false if sale_start_at.nil? || sale_end_at.nil?
sale_start_at <= Time.current && sale_end_at >= Time.current
end
def sold_out?
return false if quantity.nil?
tickets.count >= quantity
end
def available_quantity
return 0 if quantity.nil?
[ quantity - tickets.count, 0 ].max
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?
:available
end
def total_potential_revenue
return 0.0 if quantity.nil? || price_cents.nil?
quantity * price_euros
end
def current_revenue
return 0.0 if price_cents.nil?
tickets.count * price_euros
end
def remaining_potential_revenue
return 0.0 if quantity.nil? || price_cents.nil?
available_quantity * price_euros
end
private private
@@ -20,4 +77,9 @@ class TicketType < ApplicationRecord
return unless sale_start_at && sale_end_at return unless sale_start_at && sale_end_at
errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at
end end
def sale_times_within_event_period
return unless event&.start_time && sale_end_at
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
end
end end

View File

@@ -22,9 +22,22 @@ class User < ApplicationRecord
# Relationships # Relationships
has_many :events, dependent: :destroy has_many :events, dependent: :destroy
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy
has_many :orders, dependent: :destroy
# Validations # Validations - allow reasonable name lengths
validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true } validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
validates :first_name, length: { minimum: 3, maximum: 12, allow_blank: true } validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
validates :company_name, length: { minimum: 3, maximum: 12, allow_blank: true } validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
# Authorization methods
def can_manage_events?
# For now, all authenticated users can manage events
# This can be extended later with role-based permissions
true
end
def promoter?
# Alias for can_manage_events? to make views more semantic
can_manage_events?
end
end end

View File

@@ -0,0 +1,206 @@
# 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: "aperonight_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: "aperonight_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)
@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
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,97 +0,0 @@
require 'prawn'
require 'prawn/qrcode'
require 'rqrcode'
class TicketPdfGenerator
attr_reader :ticket
def initialize(ticket)
@ticket = ticket
end
def generate
Prawn::Document.new(page_size: [350, 600], margin: 20) do |pdf|
# Header
pdf.fill_color "2D1B69"
pdf.font "Helvetica", style: :bold, size: 24
pdf.text "ApéroNight", align: :center
pdf.move_down 10
# Event name
pdf.fill_color "000000"
pdf.font "Helvetica", style: :bold, size: 18
pdf.text ticket.event.name, align: :center
pdf.move_down 20
# Ticket info box
pdf.stroke_color "E5E7EB"
pdf.fill_color "F9FAFB"
pdf.rounded_rectangle [0, pdf.cursor], 310, 120, 10
pdf.fill_and_stroke
pdf.move_down 10
pdf.fill_color "000000"
pdf.font "Helvetica", size: 12
# Ticket details
pdf.text "Ticket Type:", style: :bold
pdf.text ticket.ticket_type.name
pdf.move_down 8
pdf.text "Price:", style: :bold
pdf.text "#{ticket.price_euros}"
pdf.move_down 8
pdf.text "Date & Time:", style: :bold
pdf.text ticket.event.start_time.strftime("%B %d, %Y at %I:%M %p")
pdf.move_down 20
# Venue information
pdf.fill_color "374151"
pdf.font "Helvetica", style: :bold, size: 14
pdf.text "Venue Information"
pdf.move_down 8
pdf.font "Helvetica", size: 11
pdf.text ticket.event.venue_name, style: :bold
pdf.text ticket.event.venue_address
pdf.move_down 20
# QR Code
pdf.fill_color "000000"
pdf.font "Helvetica", style: :bold, size: 14
pdf.text "Ticket QR Code", align: :center
pdf.move_down 10
qr_code_data = {
ticket_id: ticket.id,
qr_code: ticket.qr_code,
event_id: ticket.event.id,
user_id: ticket.user.id
}.to_json
qrcode = RQRCode::QRCode.new(qr_code_data)
pdf.print_qr_code(qrcode, extent: 120, align: :center)
pdf.move_down 15
# QR code text
pdf.font "Helvetica", size: 8
pdf.fill_color "6B7280"
pdf.text "QR Code: #{ticket.qr_code[0..7]}...", align: :center
# Footer
pdf.move_down 30
pdf.stroke_color "E5E7EB"
pdf.horizontal_line 0, 310
pdf.move_down 10
pdf.font "Helvetica", size: 8
pdf.fill_color "6B7280"
pdf.text "This ticket is valid for one entry only.", align: :center
pdf.text "Present this ticket at the venue entrance.", align: :center
pdf.move_down 5
pdf.text "Generated on #{Time.current.strftime('%B %d, %Y at %I:%M %p')}", align: :center
end.render
end
end

View File

@@ -0,0 +1,61 @@
<div class="min-h-screen bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
<!-- Success Icon -->
<div class="flex justify-center mb-6">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<!-- Success Message -->
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Paiement réussi !</h1>
<p class="text-gray-600">Votre commande a été confirmée et vos billets ont été envoyés par email.</p>
</div>
<!-- Order Details -->
<% if @order&.present? %>
<div class="border-t border-gray-200 pt-6 mb-6">
<div class="flex justify-between items-center mb-4">
<span class="text-sm font-medium text-gray-900">Commande #<%= @order.id %></span>
<span class="text-sm text-gray-500"><%= @order.created_at.strftime("%d/%m/%Y à %H:%M") %></span>
</div>
<div class="space-y-2 mb-4">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Événement:</span>
<span class="text-sm font-medium text-gray-900"><%= @order.event.name %></span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Nombre de billets:</span>
<span class="text-sm font-medium text-gray-900"><%= @order.tickets.count %></span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Total:</span>
<span class="text-sm font-bold text-green-600"><%= @order.total_amount_euros %>€</span>
</div>
</div>
</div>
<% end %>
<!-- Action Buttons -->
<div class="space-y-3">
<%= link_to dashboard_path, class: "w-full bg-green-600 hover:bg-green-700 text-white font-medium py-3 px-4 rounded-lg transition-colors text-center block" do %>
Voir mes billets
<% end %>
<%= link_to events_path, class: "w-full bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium py-3 px-4 rounded-lg transition-colors text-center block" do %>
Découvrir d'autres événements
<% end %>
</div>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-xs text-gray-500">
Un email de confirmation a été envoyé à votre adresse email avec vos billets en pièce jointe.
</p>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -1,41 +1,40 @@
<div class="footer-content"> <div class="grid gap-6 mb-6 md:grid-cols-2 lg:grid-cols-4">
<div class="footer-section"> <div>
<h3>Events</h3> <h3 class="font-bold text-lg text-white mb-3">À propos</h3>
<ul class="footer-links"> <ul class="space-y-2">
<li><a href="#">Find Events</a></li> <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Je suis organisateur</a></li>
<li><a href="#">Host an Event</a></li> <%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Pour les artistes</a></li> %>
<li><a href="#">Event Categories</a></li> <%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Kit presse</a></li> %>
<li><a href="#">Premium Events</a></li> <%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Artistes</a></li> %>
</ul> </ul>
</div> </div>
<div class="footer-section"> <div>
<h3>Community</h3> <h3 class="font-bold text-lg text-white mb-3">Villes</h3>
<ul class="footer-links"> <ul class="space-y-2">
<li><a href="#">Join Us</a></li> <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Paris</a></li>
<li><a href="#">Member Benefits</a></li> <%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Lyon</a></li> %>
<li><a href="#">Success Stories</a></li> <%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Marseille / Aix-en-Provence</a></li> %>
<li><a href="#">Ambassador Program</a></li> <%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Toulouse</a></li> %>
</ul> </ul>
</div> </div>
<div class="footer-section"> <%# <div>
<h3>Support</h3> <h3 class="font-bold text-lg text-white mb-3">Organisateurs</h3>
<ul class="footer-links"> <ul class="space-y-2">
<li><a href="#">Help Center</a></li> <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Help Center</a></li>
<li><a href="#">Contact Us</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="#">Safety Guidelines</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="#">Cancellation Policy</a></li> <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Cancellation Policy</a></li>
</ul> </ul>
</div> </div> %>
<div class="footer-section"> <div>
<h3>Company</h3> <h3 class="font-bold text-lg text-white mb-3">Support</h3>
<ul class="footer-links"> <ul class="space-y-2">
<li><a href="#">About Aperonight</a></li> <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Aide</a></li>
<li><a href="#">Careers</a></li> <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Nous contacter</a></li>
<li><a href="#">Press & Media</a></li> <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Signaler un contenu inapproprié</a></li>
<li><a href="#">Partner With Us</a></li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="footer-bottom"> <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="#" style="color: var(--color-accent-400);">Privacy Policy</a> • <a href="#" style="color: var(--color-accent-400);">Terms of Service</a></p> <p>&copy; 2025 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> </div>

View File

@@ -1,18 +1,22 @@
<header class="bg-neutral-800 border-b border-neutral-700"> <header class="bg-neutral-800 border-b border-neutral-700">
<nav data-controller="header" class="container mx-auto px-4 sm:px-6 lg:px-8"> <nav data-controller="header" class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex items-center h-16 justify-between">
<!-- Logo --> <!-- Logo -->
<div class="flex items-center"> <div class="flex-shrink-0 flex items-center">
<%= link_to Rails.application.config.app_name, current_user ? "/dashboard" : "/", <%= link_to Rails.application.config.app_name, current_user ? "/dashboard" : "/",
class: "text-xl font-bold text-white" %> class: "text-xl font-bold text-white" %>
</div> </div>
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<div class="hidden sm:flex items-center space-x-6"> <div class="hidden sm:flex items-center space-x-6 w-full justify-start">
<%= link_to t("header.parties"), events_path, <%= link_to "Afterworks", events_path,
class: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %> 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: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %> <%= link_to "Évenements", "#",
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
<%= link_to "Concerts", "#",
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
</div> </div>
<!-- Authentication --> <!-- Authentication -->
@@ -27,15 +31,21 @@
</svg> </svg>
</button> </button>
<div data-header-target="userMenu" class="absolute right-0 mt-2 w-48 rounded-md shadow-lg z-50 hidden"> <!-- User Dropdown Menu -->
<%= link_to t("header.profile"), edit_user_registration_path, <div data-header-target="userMenu" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
class: "block px-4 py-2 text-sm text-gray-100 hover:bg-purple-700 first:rounded-t-md" %> <div class="px-4 py-2 text-sm text-gray-900 border-b border-gray-100">
<%= link_to t("header.reservations"), "#", <div class="font-medium"><%= current_user.first_name || current_user.email %></div>
class: "block px-4 py-2 text-sm text-gray-100 hover:bg-purple-700" %> <div class="text-gray-500"><%= current_user.email %></div>
<%= link_to t("header.logout"), destroy_user_session_path, </div>
data: { controller: "logout", action: "click->logout#signOut", <%= link_to "Profile", edit_user_registration_path,
logout_url_value: destroy_user_session_path, redirect_url_value: "/", turbo: false }, class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-200" %>
class: "block px-4 py-2 text-sm text-gray-100 hover:bg-purple-700 last:rounded-b-md" %> <%= link_to "Reservations", "#",
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-200" %>
<div class="border-t border-gray-100">
<%= link_to "Sign out", 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-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-200" %>
</div>
</div> </div>
</div> </div>
<% else %> <% else %>
@@ -47,7 +57,7 @@
</div> </div>
<!-- Mobile menu button --> <!-- Mobile menu button -->
<div class="flex items-center sm:hidden"> <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"> <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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />

View File

@@ -1,5 +1,5 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8"> <div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="min-h-screen max-w-md w-full space-y-8">
<div> <div>
<%= link_to "/" do %> <%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" /> <img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />

View File

@@ -1,5 +1,5 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8"> <div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="min-h-screen max-w-md w-full space-y-8">
<div> <div>
<%= link_to "/" do %> <%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" /> <img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />

View File

@@ -1,21 +1,21 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8"> <div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="min-h-screen max-w-md w-full space-y-8">
<div> <div>
<%= link_to "/" do %> <%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" /> <img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<% end %> <% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900"> <h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
<%= t('devise.passwords.new.title') %> Mot de passe oublié ?
</h2> </h2>
<p class="mt-2 text-center text-sm text-neutral-600"> <p class="mt-2 text-center text-sm text-neutral-600">
<%= t('devise.passwords.new.description') %> Entrez votre adresse email ci-dessous et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p> </p>
</div> </div>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: "mt-8 space-y-6" }) do |f| %> <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: "mt-8 space-y-6" }) do |f| %>
<div> <div>
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %> <%= f.label :email, "Adresse Email", class: "block text-sm font-medium text-neutral-700" %>
<div class="mt-1"> <div class="mt-1">
<%= f.email_field :email, autofocus: true, autocomplete: "email", <%= 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", 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",
@@ -24,7 +24,7 @@
</div> </div>
<div> <div>
<%= f.submit t('devise.passwords.new.submit'), <%= 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" %> 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> </div>
<% end %> <% end %>
@@ -35,7 +35,7 @@
<div class="w-full border-t border-neutral-300"></div> <div class="w-full border-t border-neutral-300"></div>
</div> </div>
<div class="relative flex justify-center text-sm"> <div class="relative flex justify-center text-sm">
<span class="px-2 bg-neutral-50 text-neutral-600"> <%= t('devise.sessions.new.continue_with') %> </span> <span class="px-2 bg-neutral-50 text-neutral-600">Continuer avec</span>
</div> </div>
</div> </div>

View File

@@ -30,27 +30,25 @@
<div> <div>
<%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-medium text-neutral-700" %> <%= 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", <%= 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" %> 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>
<div> <div>
<%= f.label :password_confirmation, t('devise.registrations.edit.confirm_new_password'), class: "block text-sm font-medium text-neutral-700" %> <%= f.label :password_confirmation, "Confirmer le nouveau mot de passe", class: "block text-sm font-medium text-neutral-700" %>
<%= f.password_field :password_confirmation, autocomplete: "new-password", <%= 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" %> 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>
<div> <div>
<%= f.label :current_password, t('devise.registrations.edit.current_password'), class: "block text-sm font-medium text-neutral-700" %> <%= f.label :current_password, "Mot de passe actuel", 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", <%= 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" %> 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>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<%= f.submit t('devise.registrations.edit.update'), <%= f.submit "Mettre à jour",
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" %> 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> </div>
<% end %> <% end %>

View File

@@ -1,17 +1,12 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8"> <div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="min-h-screen max-w-md w-full space-y-8">
<div> <div>
<%= link_to "/" do %> <%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" /> <img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<% end %> <% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900"> <h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">Créer un compte</h2>
<%= t('devise.registrations.new.title') %>
</h2>
<p class="mt-2 text-center text-sm text-neutral-600"> <p class="mt-2 text-center text-sm text-neutral-600">
<%= t('devise.registrations.new.or') %> ou <a href="<%= new_user_session_path %>" class="font-medium text-purple-600 hover:text-purple-500">se connecter à votre compte</a>
<a href="<%= new_user_session_path %>" class="font-medium text-purple-600 hover:text-purple-500">
<%= t('devise.registrations.new.sign_in_link') %>
</a>
</p> </p>
</div> </div>
@@ -19,13 +14,13 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %> <%= f.label :email, "Adresse email", class: "block text-sm font-medium text-neutral-700" %>
<%= f.email_field :email, autofocus: true, autocomplete: "email", <%= 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" %> 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> <div>
<%= f.label :password, class: "block text-sm font-medium text-neutral-700" %> <%= f.label :password, "Mot de passe", class: "block text-sm font-medium text-neutral-700" %>
<% if @minimum_password_length %> <% if @minimum_password_length %>
<em class="text-sm text-neutral-500">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</em> <em class="text-sm text-neutral-500">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</em>
<% end %> <% end %>
@@ -34,14 +29,14 @@
</div> </div>
<div> <div>
<%= f.label :password_confirmation, class: "block text-sm font-medium text-neutral-700" %> <%= 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", <%= 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" %> 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> </div>
<div class="actions"> <div class="acthons">
<%= f.submit t('devise.registrations.new.sign_up'), 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" %> <%= 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> </div>
<% end %> <% end %>
@@ -51,7 +46,7 @@
<div class="w-full border-t border-neutral-300"></div> <div class="w-full border-t border-neutral-300"></div>
</div> </div>
<div class="relative flex justify-center text-sm"> <div class="relative flex justify-center text-sm">
<span class="px-2 bg-neutral-50 text-neutral-600"> <%= t('devise.registrations.new.continue_with') %> </span> <span class="px-2 bg-neutral-50 text-neutral-600">Continuer avec</span>
</div> </div>
</div> </div>

View File

@@ -1,16 +1,16 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8"> <div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="min-h-screen max-w-md w-full space-y-8">
<div> <div>
<%= link_to "/" do %> <%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" /> <img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<% end %> <% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900"> <h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
<%= t('devise.sessions.new.title') %> Se connecter à votre compte
</h2> </h2>
<p class="mt-2 text-center text-sm text-neutral-600"> <p class="mt-2 text-center text-sm text-neutral-600">
<%= t('devise.sessions.new.or') %> ou
<a href="<%= new_user_registration_path %>" class="font-medium text-purple-600 hover:text-purple-500"> <a href="<%= new_user_registration_path %>" class="font-medium text-purple-600 hover:text-purple-500">
<%= t('devise.sessions.new.sign_up_link') %> créer un compte
</a> </a>
</p> </p>
</div> </div>
@@ -19,17 +19,17 @@
<div class="rounded-md shadow-sm -space-y-px"> <div class="rounded-md shadow-sm -space-y-px">
<div class="field"> <div class="field">
<%= f.label :email, class: "sr-only" %> <%= f.label :email, "Email", class: "sr-only" %>
<%= f.email_field :email, autofocus: true, autocomplete: "email", <%= 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", 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: t('devise.sessions.new.email_placeholder') %> placeholder: "Adresse email" %>
</div> </div>
<div class="field"> <div class="field">
<%= f.label :password, class: "sr-only" %> <%= f.label :password, "Mot de passe", class: "sr-only" %>
<%= f.password_field :password, autocomplete: "current-password", <%= 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", 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: t('devise.sessions.new.password_placeholder') %> placeholder: "Mot de passe" %>
</div> </div>
</div> </div>
@@ -37,14 +37,14 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <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" %> <%= 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"> <%= t('devise.sessions.new.remember_me') %> </label> <label for="user_remember_me" class="ml-2 block text-sm text-neutral-700"> Se souvenir de moi </label>
</div> </div>
</div> </div>
<% end %> <% end %>
<div class="actions"> <div class="actions">
<%= f.submit t('devise.sessions.new.sign_in'), 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" %> <%= 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> </div>
<% end %> <% end %>
@@ -54,7 +54,7 @@
<div class="w-full border-t border-neutral-300"></div> <div class="w-full border-t border-neutral-300"></div>
</div> </div>
<div class="relative flex justify-center text-sm"> <div class="relative flex justify-center text-sm">
<span class="px-2 bg-neutral-50 text-neutral-600"> <%= t('devise.sessions.new.continue_with') %> </span> <span class="px-2 bg-neutral-50 text-neutral-600">Continuer avec</span>
</div> </div>
</div> </div>

View File

@@ -1,38 +1,38 @@
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<%- if controller_name != "sessions" %> <%- 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"> <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 t('devise.shared.links.sign_in'), new_session_path(resource_name), class: "block" %> <%= link_to "Se connecter", new_session_path(resource_name), class: "block" %>
</div> </div>
<% end %> <% end %>
<%- if devise_mapping.registerable? && controller_name != "registrations" %> <%- 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"> <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 t('devise.shared.links.sign_up'), new_registration_path(resource_name), class: "block" %> <%= link_to "Créer un compte", new_registration_path(resource_name), class: "block" %>
</div> </div>
<% end %> <% end %>
<%- if devise_mapping.recoverable? && controller_name != "passwords" && controller_name != "registrations" %> <%- 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"> <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 t('devise.shared.links.forgot_password'), new_password_path(resource_name), class: "block" %> <%= link_to "Mot de passe oublié ?", new_password_path(resource_name), class: "block" %>
</div> </div>
<% end %> <% end %>
<%- if devise_mapping.confirmable? && controller_name != "confirmations" %> <%- 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"> <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 t('devise.shared.links.confirmation_instructions'), new_confirmation_path(resource_name), class: "block" %> <%= link_to "Renvoyer le lien de confirmation", new_confirmation_path(resource_name), class: "block" %>
</div> </div>
<% end %> <% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != "unlocks" %> <%- 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"> <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 t('devise.shared.links.unlock_instructions'), new_unlock_path(resource_name), class: "block" %> <%= link_to "Renvoyer le lien de déblocage", new_unlock_path(resource_name), class: "block" %>
</div> </div>
<% end %> <% end %>
<%- if devise_mapping.omniauthable? %> <%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %> <%- 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"> <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 t('devise.shared.links.sign_in_with', provider: OmniAuth::Utils.camelize(provider)), omniauth_authorize_path(resource_name, provider), data: { turbo: false }, class: "block" %> <%= button_to "Se connecter avec #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false }, class: "block" %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -1,4 +1,4 @@
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="container min-h-screen mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-center my-8"> <div class="flex justify-between items-center my-8">
<h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1> <h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1>
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
@@ -6,6 +6,7 @@
événements trouvés événements trouvés
</div> </div>
</div> </div>
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb"> <nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm"> <ol class="flex items-center space-x-2 text-sm">
@@ -55,7 +56,7 @@
> >
<% if event.image.present? %> <% if event.image.present? %>
<div class="h-48 overflow-hidden"> <div class="h-48 overflow-hidden">
<%= link_to event_path(event.slug, event) do %> <%= link_to event_path(event) do %>
<img <img
src="<%= event.image %>" src="<%= event.image %>"
alt="<%= event.name %>" alt="<%= event.name %>"
@@ -129,7 +130,7 @@
<% end %> <% end %>
</div> </div>
<%= link_to event_path(event.slug, event), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200" do %> <%= link_to event_path(event), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200" do %>
Détails Détails
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path

View File

@@ -1,23 +1,69 @@
<div <!-- SEO Meta Tags for Event -->
class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100" <% content_for :title, "#{@event.name} - #{@event.start_time.strftime('%d/%m/%Y')} | Aperonight" %>
> <% content_for :description, @event.description.truncate(160) %>
<% content_for :keywords, "#{@event.name}, événement, soirée, #{@event.venue_name}, billets, réservation" %>
<% content_for :canonical_url, seo_event_path(@event) %>
<% content_for :og_image, @event.image if @event.image.present? %>
<!-- Structured Data for Event -->
<% content_for :head do %>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Event",
"name": "<%= @event.name %>",
"description": "<%= strip_tags(@event.description) %>",
"startDate": "<%= @event.start_time.iso8601 %>",
"endDate": "<%= @event.end_time&.iso8601 || (@event.start_time + 4.hours).iso8601 %>",
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
"location": {
"@type": "Place",
"name": "<%= @event.venue_name %>",
"address": {
"@type": "PostalAddress",
"streetAddress": "<%= @event.venue_address %>"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": <%= @event.latitude %>,
"longitude": <%= @event.longitude %>
}
},
"organizer": {
"@type": "Organization",
"name": "<%= @event.user.company_name.present? ? @event.user.company_name : "#{@event.user.first_name} #{@event.user.last_name}" %>",
"email": "<%= @event.user.email %>"
},
<% if @event.image.present? %>
"image": [
"<%= @event.image %>"
],
<% end %>
"offers": [
<% @event.ticket_types.each_with_index do |ticket_type, index| %>
{
"@type": "Offer",
"name": "<%= ticket_type.name %>",
"price": "<%= ticket_type.price_cents / 100.0 %>",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock",
"url": "<%= seo_book_tickets_path(@event) %>"
}<%= ',' if index < @event.ticket_types.count - 1 %>
<% end %>
]
}
</script>
<% end %>
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb"> <nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm"> <ol class="flex items-center space-x-2 text-sm">
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %> <%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<svg <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="w-4 h-4 inline-block mr-1" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg> </svg>
Accueil Accueil
<% end %> <% end %>
@@ -56,7 +102,8 @@
</ol> </ol>
</nav> </nav>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden"> <!-- Event main wrapper -->
<div class="bg-white rounded-xl shadow-xl overflow-hidden">
<!-- Event Header with Image --> <!-- Event Header with Image -->
<% if @event.image.present? %> <% if @event.image.present? %>
<div class="relative h-96"> <div class="relative h-96">
@@ -68,7 +115,7 @@
></div> ></div>
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8"> <div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h1 class="text-3xl md:text-4xl font-bold text-white mb-2"><%= @event.name %></h1> <h1 class="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left"><%= @event.name %></h1>
</div> </div>
</div> </div>
</div> </div>
@@ -208,7 +255,7 @@
<!-- Right Column: Ticket Selection --> <!-- Right Column: Ticket Selection -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<%= form_with url: "#", method: :post, id: "checkout_form", local: true, data: { <%= form_with url: book_event_tickets_path(year: @event.start_time.year, month: format("%02d", @event.start_time.month), slug: @event.slug), method: :get, id: "checkout_form", local: true, data: {
controller: "ticket-selection", controller: "ticket-selection",
ticket_selection_target: "form", ticket_selection_target: "form",
ticket_selection_event_slug_value: @event.slug, ticket_selection_event_slug_value: @event.slug,

View File

@@ -1,10 +1,31 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title><%= content_for(:title) || "Aperonight" %></title> <title><%= content_for(:title) || "Aperonight - Événements et Soirées" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<!-- SEO Meta Tags -->
<meta name="description" content="<%= content_for(:description) || "Découvrez et réservez vos billets pour les meilleurs événements et soirées. Aperonight vous connecte aux événements incontournables près de chez vous." %>">
<meta name="keywords" content="<%= content_for(:keywords) || "événements, soirées, billets, réservation, nightlife, fêtes" %>">
<meta name="robots" content="index, follow">
<link rel="canonical" href="<%= content_for(:canonical_url) || request.original_url %>">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="<%= request.original_url %>">
<meta property="og:title" content="<%= content_for(:title) || "Aperonight - Événements et Soirées" %>">
<meta property="og:description" content="<%= content_for(:description) || "Découvrez et réservez vos billets pour les meilleurs événements et soirées." %>">
<meta property="og:image" content="<%= content_for(:og_image) || asset_url('aperonight-og-image.jpg') %>">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="<%= request.original_url %>">
<meta property="twitter:title" content="<%= content_for(:title) || "Aperonight - Événements et Soirées" %>">
<meta property="twitter:description" content="<%= content_for(:description) || "Découvrez et réservez vos billets pour les meilleurs événements et soirées." %>">
<meta property="twitter:image" content="<%= content_for(:og_image) || asset_url('aperonight-og-image.jpg') %>">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
<%= yield :head %> <%= yield :head %>
@@ -14,8 +35,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<!-- Lucide Icons --> <!-- Lucide Icons loaded via npm package -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
@@ -24,6 +44,7 @@
<link rel="apple-touch-icon" href="/icon.png"> <link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %> <%# Includes all stylesheet files in app/assets/stylesheets %>
<%# stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
@@ -41,20 +62,11 @@
</div> </div>
</main> </main>
<footer class="footer"> <footer class="bg-neutral-800 text-neutral-300 py-8 pb-4">
<div class="container"> <div class="container">
<%= render "components/footer" %> <%= render "components/footer" %>
</div> </div>
</footer> </footer>
</div> </div>
<script>
// Initialize Lucide icons
document.addEventListener("DOMContentLoaded", function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title><%= yield :title %></title>
<%= stylesheet_link_tag "pdf" %>
</head>
<body>
<%= yield %>
</body>
</html>

View File

@@ -0,0 +1,305 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Breadcrumb -->
<nav class="mb-8" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm">
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
Accueil
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
Événements
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<%= link_to event_path(@order.event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<%= @order.event.name %>
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<li class="font-medium text-gray-900" aria-current="page">Commande #<%= @order.id %></li>
</ol>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Order Summary -->
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 h-fit">
<!-- Warning for expiring order -->
<% if @expiring_soon %>
<div class="mb-6 bg-orange-50 border border-orange-200 rounded-lg p-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-orange-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<h3 class="font-medium text-orange-800 mb-1">Attention - Commande bientôt expirée</h3>
<p class="text-orange-700 text-sm">Votre commande va expirer dans quelques minutes. Veuillez procéder rapidement au paiement pour éviter son expiration automatique.</p>
</div>
</div>
</div>
<% end %>
<!-- Payment attempts warning -->
<% if @order.payment_attempts > 0 %>
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<h3 class="font-medium text-blue-800 mb-1">Nouvelle tentative de paiement</h3>
<p class="text-blue-700 text-sm">
Tentative <%= @order.payment_attempts + 1 %> sur <%= @order.class::MAX_PAYMENT_ATTEMPTS %>.
<% if @order.payment_attempts >= @order.class::MAX_PAYMENT_ATTEMPTS - 1 %>
<strong>Dernière tentative avant expiration !</strong>
<% end %>
</p>
</div>
</div>
</div>
<% end %>
<div class="border-b border-gray-200 pb-6 mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Commande pour <%= @order.event.name %></h1>
<div class="flex items-center text-sm text-gray-600 space-x-4">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<% if @order.expires_at %>
Expire dans <%= time_ago_in_words(@order.expires_at, include_seconds: true) %>
<% end %>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Commande #<%= @order.id %>
</div>
</div>
</div>
<!-- Order Items -->
<div class="space-y-4 mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Récapitulatif de votre commande</h3>
<% @tickets.each do |ticket| %>
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate"><%= ticket.ticket_type.name %></h4>
<div class="flex items-center text-xs text-gray-500 mt-1">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<%= ticket.first_name %> <%= ticket.last_name %>
</div>
</div>
<div class="text-right">
<div class="text-lg font-semibold text-gray-900"><%= ticket.price_euros %>€</div>
<% if ticket.ticket_type.description.present? %>
<div class="text-xs text-gray-500"><%= truncate(ticket.ticket_type.description, length: 30) %></div>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Order Total -->
<div class="border-t border-gray-200 pt-6">
<div class="flex items-center justify-between text-lg">
<span class="font-medium text-gray-900">Total</span>
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
</div>
<p class="text-xs text-gray-500 mt-2">TVA incluse</p>
</div>
</div>
<!-- Payment Section -->
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 h-fit">
<div class="border-b border-gray-200 pb-6 mb-6">
<h2 class="text-xl font-bold text-gray-900 mb-2">Paiement sécurisé</h2>
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
</div>
<% if @checkout_session.present? %>
<!-- Stripe Checkout -->
<div class="space-y-6">
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-lg p-4 border border-purple-200">
<div class="flex items-start">
<svg class="w-5 h-5 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<div>
<h3 class="font-medium text-purple-800 mb-1">Paiement 100% sécurisé</h3>
<p class="text-purple-700 text-sm">Vos données bancaires sont protégées par le cryptage SSL et traitées par Stripe, leader mondial du paiement en ligne.</p>
</div>
</div>
</div>
<button
id="checkout-button"
class="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-bold py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
>
<div class="flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
Payer <%= @order.total_amount_euros %>€
</div>
</button>
<div class="flex items-center justify-center space-x-4 text-xs text-gray-500">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
Visa
</span>
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
Mastercard
</span>
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
Sécurisé par Stripe
</span>
</div>
<script src="https://js.stripe.com/v3/"></script>
<script>
// Wait for Stripe library to load and DOM to be ready
function initializeStripeCheckout() {
if (typeof Stripe === 'undefined') {
console.log('Waiting for Stripe library to load...');
setTimeout(initializeStripeCheckout, 100);
return;
}
console.log('Initializing Stripe with publishable key:', '<%= Rails.application.config.stripe[:publishable_key] %>');
const stripe = Stripe('<%= Rails.application.config.stripe[:publishable_key] %>');
const checkoutButton = document.getElementById('checkout-button');
if (!checkoutButton) {
console.error('Checkout button not found');
return;
}
checkoutButton.addEventListener('click', async function() {
console.log('Checkout button clicked');
const button = this;
button.disabled = true;
button.innerHTML = `
<div class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Initialisation du paiement...
</div>
`;
try {
// Increment payment attempt counter
console.log('Incrementing payment attempt for order:', '<%= @order.id %>');
const response = await fetch('<%= increment_payment_attempt_order_path(@order) %>', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.error('Payment attempt increment failed:', response.status, response.statusText);
throw new Error('Failed to increment payment attempt');
}
console.log('Payment attempt incremented successfully');
// Update button text for redirect
button.innerHTML = `
<div class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Redirection vers le paiement...
</div>
`;
// Redirect to Stripe
console.log('Redirecting to Stripe with session ID:', '<%= @checkout_session&.id %>');
const stripeResult = await stripe.redirectToCheckout({
sessionId: '<%= @checkout_session.id %>'
});
if (stripeResult.error) {
throw new Error(stripeResult.error.message);
}
} catch (error) {
console.error('Checkout error:', error);
// Reset button on error
button.disabled = false;
button.innerHTML = `
<div class="flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
Payer <%= @order.total_amount_euros %>€
</div>
`;
alert('Erreur: ' + error.message);
}
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeStripeCheckout);
} else {
initializeStripeCheckout();
}
</script>
</div>
<% else %>
<!-- No Stripe Configuration -->
<div class="text-center py-8">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<svg class="w-12 h-12 text-yellow-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="font-semibold text-yellow-800 mb-2">Paiement temporairement indisponible</h3>
<p class="text-yellow-700 text-sm">Le système de paiement n'est pas encore configuré. Veuillez contacter l'organisateur pour plus d'informations.</p>
</div>
</div>
<% end %>
<!-- Order Actions -->
<div class="border-t border-gray-200 pt-6 mt-6">
<div class="space-y-3">
<%= link_to event_path(@order.event), class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %>
<div class="flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Retour à l'événement
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,140 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<nav class="mb-8" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm">
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
Accueil
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
Événements
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<%= link_to event_path(@event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<%= @event.name %>
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<li class="font-medium text-gray-900" aria-current="page">
Nouvelle commande
</li>
</ol>
</nav>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Nouvelle Commande</h1>
<p class="text-gray-600">Vérifiez vos billets et indiquez les noms des participants</p>
</div>
<!-- Order Summary -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Résumé de votre commande</h2>
<% if @event %>
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-800"><%= @event.name %></h3>
<p class="text-gray-600"><%= @event.venue_name %></p>
<% if @event.start_time %>
<p class="text-sm text-gray-500">
<%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %>
</p>
<% end %>
</div>
<!-- Cart Items -->
<div class="space-y-4 mb-6">
<% @cart_data.each do |ticket_type_id, item| %>
<% ticket_type = @event.ticket_types.find_by(id: ticket_type_id) %>
<% if ticket_type && item["quantity"].to_i > 0 %>
<div class="flex justify-between items-center py-3 border-b border-gray-100">
<div>
<h4 class="font-medium text-gray-900"><%= ticket_type.name %></h4>
<p class="text-sm text-gray-600"><%= ticket_type.description if ticket_type.description.present? %></p>
</div>
<div class="text-right">
<p class="font-medium">Quantité: <%= item["quantity"] %></p>
<p class="text-lg font-semibold text-purple-700">
<%= number_to_currency(ticket_type.price_cents * item["quantity"].to_i / 100.0, unit: "€", separator: ",", delimiter: " ") %>
</p>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
<!-- Name Collection Form -->
<% if @tickets_needing_names.any? %>
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 mt-6">
<div class="p-6 md:p-8">
<div class="text-center mb-8">
<div class="mx-auto bg-purple-100 rounded-full p-3 w-16 h-16 flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">Informations des participants</h2>
<p class="text-gray-600 max-w-md mx-auto">Veuillez fournir les prénoms et noms des personnes qui utiliseront les billets.</p>
</div>
<%= form_with url: event_orders_path(@event), method: :post, local: true, class: "space-y-8" do |form| %>
<div class="space-y-6">
<div class="flex items-center justify-center mb-2">
<div class="bg-purple-600 rounded-full p-2 mr-3">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900">Billets nécessitant une identification</h3>
</div>
<p class="text-gray-600 mb-6 text-center">Les billets suivants nécessitent que vous indiquiez le prénom et le nom de chaque participant.</p>
<% @tickets_needing_names.each_with_index do |ticket, index| %>
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl p-6 border border-purple-100">
<div class="flex items-center mb-4">
<div class="bg-purple-500 rounded-lg p-2 mr-3">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<h4 class="text-lg font-semibold text-gray-900"><%= ticket[:ticket_type_name] %> #<%= index + 1 %></h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<%= form.label "tickets_attributes[#{index}][first_name]", "Prénom", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= form.text_field "tickets_attributes[#{index}][first_name]", required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 shadow-sm", placeholder: "Entrez le prénom" %>
<%= form.hidden_field "tickets_attributes[#{index}][ticket_type_id]", value: ticket[:ticket_type_id] %>
</div>
<div>
<%= form.label "tickets_attributes[#{index}][last_name]", "Nom", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= form.text_field "tickets_attributes[#{index}][last_name]", required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 shadow-sm", placeholder: "Entrez le nom" %>
</div>
</div>
</div>
<% end %>
</div>
<div class="flex flex-col sm:flex-row gap-4 pt-6">
<%= link_to "Retour", event_path(@event), class: "px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 text-center font-medium transition-colors duration-200" %>
<%= form.submit "Procéder au paiement", class: "flex-1 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-3 px-6 rounded-xl shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transform hover:-translate-y-0.5" %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,191 @@
<div class="min-h-screen bg-gradient-to-br from-green-50 to-emerald-50 py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Success Header -->
<div class="text-center mb-12">
<div class="mx-auto w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mb-6">
<svg class="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h1 class="text-4xl font-bold text-gray-900 mb-4">Paiement réussi !</h1>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
Félicitations ! Votre commande a été traitée avec succès. Vous allez recevoir vos billets par email d'ici quelques minutes.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Order Summary -->
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8">
<div class="border-b border-gray-200 pb-6 mb-6">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Récapitulatif de la commande</h2>
<div class="flex items-center text-sm text-gray-600 space-x-4">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Commande #<%= @order.id %>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="text-green-600 font-medium">Payée</span>
</div>
</div>
</div>
<!-- Event Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Événement</h3>
<div class="bg-purple-50 rounded-lg p-4 border border-purple-200">
<h4 class="font-semibold text-purple-900 text-lg"><%= @order.event.name %></h4>
<div class="mt-2 space-y-1 text-sm text-purple-700">
<% if @order.event.start_time %>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<%= l(@order.event.start_time, format: :long) %>
</div>
<% end %>
<% if @order.event.venue_name.present? %>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<%= @order.event.venue_name %>
</div>
<% end %>
</div>
</div>
</div>
<!-- Tickets List -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Vos billets</h3>
<% @order.tickets.each do |ticket| %>
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate"><%= ticket.ticket_type.name %></h4>
<div class="flex items-center text-xs text-gray-500 mt-1">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<%= ticket.first_name %> <%= ticket.last_name %>
</div>
<div class="flex items-center text-xs text-green-600 mt-1">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
Actif
</div>
</div>
<div class="text-right">
<div class="text-lg font-semibold text-gray-900"><%= ticket.price_euros %>€</div>
</div>
</div>
<% end %>
</div>
<!-- Total -->
<div class="border-t border-gray-200 pt-6 mt-6">
<div class="flex items-center justify-between text-lg">
<span class="font-medium text-gray-900">Total payé</span>
<span class="font-bold text-2xl text-green-600"><%= @order.total_amount_euros %>€</span>
</div>
</div>
</div>
<!-- Next Steps -->
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 h-fit">
<div class="border-b border-gray-200 pb-6 mb-6">
<h2 class="text-xl font-bold text-gray-900 mb-2">Prochaines étapes</h2>
<p class="text-sm text-gray-600">Que faire maintenant ?</p>
</div>
<div class="space-y-6">
<!-- Email Confirmation -->
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<span class="text-blue-600 font-semibold text-sm">1</span>
</div>
<div class="ml-4">
<h3 class="font-semibold text-gray-900 mb-1">Vérifiez votre email</h3>
<p class="text-gray-600 text-sm">Nous avons envoyé vos billets à <strong><%= current_user.email %></strong>. Vérifiez aussi vos spams.</p>
</div>
</div>
<!-- Download Tickets -->
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<span class="text-purple-600 font-semibold text-sm">2</span>
</div>
<div class="ml-4">
<h3 class="font-semibold text-gray-900 mb-1">Téléchargez vos billets</h3>
<p class="text-gray-600 text-sm mb-3">Gardez vos billets sur votre téléphone ou imprimez-les.</p>
<div class="space-y-2">
<% @order.tickets.each do |ticket| %>
<%= link_to download_ticket_path(ticket), class: "inline-flex items-center px-3 py-2 border border-purple-300 rounded-md text-sm font-medium text-purple-700 bg-purple-50 hover:bg-purple-100 transition-colors mr-2 mb-2" do %>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<%= ticket.first_name %> <%= ticket.last_name %>
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Event Day -->
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<span class="text-green-600 font-semibold text-sm">3</span>
</div>
<div class="ml-4">
<h3 class="font-semibold text-gray-900 mb-1">Le jour J</h3>
<p class="text-gray-600 text-sm">Présentez votre billet (QR code) à l'entrée. Arrivez un peu en avance !</p>
</div>
</div>
</div>
<!-- Contact Support -->
<div class="bg-gray-50 rounded-lg p-4 mt-8">
<h4 class="font-medium text-gray-900 mb-2">Besoin d'aide ?</h4>
<p class="text-gray-600 text-sm mb-3">Si vous avez des questions ou des problèmes avec votre commande, n'hésitez pas à nous contacter.</p>
<div class="space-y-2">
<%= link_to "mailto:support@example.com", class: "inline-flex items-center text-sm text-purple-600 hover:text-purple-700" do %>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
Contactez le support
<% end %>
</div>
</div>
<!-- Actions -->
<div class="border-t border-gray-200 pt-6 mt-6">
<div class="space-y-3">
<%= link_to dashboard_path, class: "block w-full text-center py-3 px-4 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors" do %>
<div class="flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
Voir tous mes billets
</div>
<% end %>
<%= link_to events_path, class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %>
<div class="flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Découvrir d'autres événements
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,104 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Breadcrumb -->
<nav class="mb-8" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm">
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
Accueil
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
Événements
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<%= link_to seo_event_path(@order.event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<%= @order.event.name %>
<% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<li class="font-medium text-gray-900" aria-current="page">Commande #<%= @order.id %></li>
</ol>
</nav>
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8">
<div class="border-b border-gray-200 pb-6 mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Détails de la commande</h1>
<div class="flex items-center text-sm text-gray-600 space-x-4">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Commande #<%= @order.id %>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<%= @order.status.titleize %>
</div>
</div>
</div>
<!-- Order Items -->
<div class="space-y-4 mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Billets commandés</h3>
<% @tickets.each do |ticket| %>
<div class="flex items-center justify-between py-4 border-b border-gray-100 last:border-b-0">
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900"><%= ticket.ticket_type.name %></h4>
<div class="flex items-center text-xs text-gray-500 mt-1">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<%= ticket.first_name %> <%= ticket.last_name %>
</div>
<div class="text-xs text-gray-500 mt-1">
Statut: <%= ticket.status.titleize %>
</div>
</div>
<div class="text-right">
<div class="text-lg font-semibold text-gray-900"><%= ticket.price_euros %>€</div>
</div>
</div>
<% end %>
</div>
<!-- Order Total -->
<div class="border-t border-gray-200 pt-6">
<div class="flex items-center justify-between text-lg">
<span class="font-medium text-gray-900">Total</span>
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
</div>
<p class="text-xs text-gray-500 mt-2">TVA incluse</p>
</div>
<!-- Actions -->
<div class="border-t border-gray-200 pt-6 mt-6">
<div class="flex space-x-4">
<%= link_to seo_event_path(@order.event), class: "bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium py-2 px-4 rounded-lg transition-colors" do %>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Retour à l'événement
</div>
<% end %>
<% if @order.can_retry_payment? %>
<%= link_to booking_summary_path(@order), class: "bg-purple-600 hover:bg-purple-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" do %>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
Procéder au paiement
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,7 +1,23 @@
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Hero section with metrics --> <!-- Hero section with metrics -->
<div class="mb-8"> <div class="mt-4 mb-8">
<h1 class="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-6">Tableau de bord</h1> <div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold text-slate-900 dark:text-slate-100">Tableau de bord</h1>
<!-- Promoter Actions -->
<% if current_user.promoter? %>
<div class="flex items-center space-x-3">
<%= link_to promoter_events_path, class: "inline-flex items-center px-4 py-2 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
<i data-lucide="calendar-plus" class="w-4 h-4 mr-2"></i>
Mes événements
<% end %>
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-4 py-2 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer un événement
<% end %>
</div>
<% end %>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<%= render partial: 'components/metric_card', locals: { title: "Mes réservations", value: @booked_events, classes: "from-green-100 to-emerald-100" } %> <%= render partial: 'components/metric_card', locals: { title: "Mes réservations", value: @booked_events, classes: "from-green-100 to-emerald-100" } %>
@@ -15,8 +31,8 @@
</div> </div>
</div> </div>
<!-- Draft tickets needing payment --> <!-- Draft orders needing payment -->
<% if @draft_tickets.any? %> <% if @draft_orders.any? %>
<div class="card hover-lift mb-8 border-orange-200 bg-orange-50"> <div class="card hover-lift mb-8 border-orange-200 bg-orange-50">
<div class="card-header bg-orange-100 rounded-lg"> <div class="card-header bg-orange-100 rounded-lg">
@@ -25,40 +41,39 @@
<svg class="w-6 h-6 mr-2 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 mr-2 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
Billets en attente de paiement Commandes en attente de paiement
</h2> </h2>
<p class="text-orange-700 mt-1">Vous avez des billets qui nécessitent un paiement</p> <p class="text-orange-700 mt-1">Vous avez des commandes qui nécessitent un paiement</p>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="space-y-4"> <div class="space-y-4">
<% @draft_tickets.group_by(&:event).each do |event, tickets| %> <% @draft_orders.each do |order| %>
<div class="bg-white rounded-lg p-4 border border-orange-200"> <div class="bg-white rounded-lg p-4 border border-orange-200">
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div> <div>
<h3 class="font-semibold text-gray-900"><%= event.name %></h3> <h3 class="font-semibold text-gray-900"><%= order.event.name %></h3>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
<%= event.start_time.strftime("%d %B %Y à %H:%M") %> <%= order.event.start_time.strftime("%d %B %Y à %H:%M") %>
</p> </p>
</div> </div>
<span class="text-sm font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full"> <span class="text-sm font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full">
<%= tickets.count %> billet<%= 's' if tickets.count > 1 %> Commande #<%= order.id %>
</span> </span>
</div> </div>
<div class="grid gap-2 mb-4"> <div class="grid gap-2 mb-4">
<% tickets.each do |ticket| %> <% order.tickets.each do |ticket| %>
<div class="flex items-center justify-between text-sm bg-gray-50 rounded p-2"> <div class="flex items-center justify-between text-sm bg-gray-50 rounded p-2">
<div> <div>
<span class="font-medium"><%= ticket.ticket_type.name %></span> <span class="font-medium"><%= ticket.ticket_type.name %></span>
<span class="text-gray-600">- <%= ticket.first_name %> <%= ticket.last_name %></span> <span class="text-gray-600">- <%= ticket.first_name %> <%= ticket.last_name %></span>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="text-gray-600">Expire <%= time_ago_in_words(ticket.expires_at) %></span>
<span class="font-medium text-gray-900"><%= number_to_currency(ticket.price_euros, unit: "€") %></span> <span class="font-medium text-gray-900"><%= number_to_currency(ticket.price_euros, unit: "€") %></span>
</div> </div>
</div> </div>
@@ -67,17 +82,17 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
<% max_attempts = tickets.map(&:payment_attempts).max %> Tentatives: <%= order.payment_attempts %>/3
Tentatives: <%= max_attempts %>/3 <% if order.expiring_soon? %>
<% if tickets.any?(&:expiring_soon?) %> <span class="text-orange-600 font-medium ml-2">⚠️ Expire dans <%= time_ago_in_words(order.expires_at) %></span>
<span class="text-orange-600 font-medium ml-2">⚠️ Expire bientôt</span> <% else %>
<span class="text-gray-500 ml-2">Expire dans <%= time_ago_in_words(order.expires_at) %></span>
<% end %> <% end %>
</div> </div>
<%= form_tag ticket_retry_payment_path(event.slug, event.id), method: :post do %> <%= link_to retry_payment_order_path(order), method: :post,
<%= hidden_field_tag :ticket_ids, tickets.map(&:id).join(',') %> class: "inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm font-medium rounded-lg hover:bg-orange-700 transition-colors duration-200" do %>
<%= submit_tag "Reprendre le paiement", Reprendre le paiement (<%= order.total_amount_euros %>€)
class: "inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm font-medium rounded-lg hover:bg-orange-700 transition-colors duration-200" %>
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@@ -26,9 +26,9 @@
</div> </div>
<div class="featured-events-grid" data-controller="featured-event"> <div class="featured-events-grid" data-controller="featured-event">
<% @events.each do |event| %> <% @featured_events.each do |event| %>
<div class="featured-event-card" data-featured-event-target="card"> <div class="featured-event-card" data-featured-event-target="card">
<%= link_to event_path(event.slug, event) do %> <%= link_to event_path(event) do %>
<img src="<%= event.image %>" alt="<%= event.name %>" class="featured-event-image" data-featured-event-target="animated"> <img src="<%= event.image %>" alt="<%= event.name %>" class="featured-event-image" data-featured-event-target="animated">
<% end %> <% end %>
<div class="featured-event-content"> <div class="featured-event-content">
@@ -36,8 +36,8 @@
<% if event.featured? %> <% if event.featured? %>
<span class="badge badge-featured">★ En vedette</span> <span class="badge badge-featured">★ En vedette</span>
<% end %> <% end %>
<% if event.tickets.any? { |ticket| ticket.quantity > 0 } %> <% if event.ticket_types.any? { |ticket_type| ticket_type.available_quantity > 0 } %>
<span class="badge badge-available">Disponible</span> <!--<span class="badge badge-available">Disponible</span>-->
<% end %> <% end %>
</div> </div>
<h3 class="featured-event-title"><%= event.name %></h3> <h3 class="featured-event-title"><%= event.name %></h3>
@@ -58,7 +58,7 @@
<p class="featured-event-description"><%= event.description %></p> <p class="featured-event-description"><%= event.description %></p>
<div class="featured-event-footer"> <div class="featured-event-footer">
<span class="featured-event-price">€<%= event.ticket_types.minimum(:price_cents).to_f / 100 %></span> <span class="featured-event-price">€<%= event.ticket_types.minimum(:price_cents).to_f / 100 %></span>
<%= link_to "Réserver une place", event_path(event.slug, event), class: "btn btn-sm btn-primary" %> <%= link_to "Réserver une place", event_path(event), class: "btn btn-sm btn-primary" %>
</div> </div>
</div> </div>
</div> </div>
@@ -76,37 +76,37 @@
<section class="section features-section"> <section class="section features-section">
<div class="container"> <div class="container">
<div class="section-header"> <div class="section-header">
<h2 class="section-title">Why Choose Aperonight?</h2> <h2 class="section-title">Pourquoi choisir Aperonight ?</h2>
<p class="section-description">We curate premium experiences that connect professionals and create lasting relationships.</p> <p class="section-description">Nous sélectionnons des expériences premium qui connectent les professionnels et créent des relations durables.</p>
</div> </div>
<div class="features-grid"> <div class="features-grid">
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon"> <div class="feature-icon">
<i data-lucide="crown"></i> <i data-lucide="crown"></i>
</div> </div>
<h3 class="feature-title">Premium Curation</h3> <h3 class="feature-title">Sélection Premium</h3>
<p class="feature-description">Every event is carefully selected and designed to provide exceptional value and networking opportunities.</p> <p class="feature-description">Chaque événement est soigneusement sélectionné et conçu pour offrir une valeur exceptionnelle et des opportunités de réseautage.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon"> <div class="feature-icon">
<i data-lucide="shield-check"></i> <i data-lucide="shield-check"></i>
</div> </div>
<h3 class="feature-title">Secure & Trusted</h3> <h3 class="feature-title">Sécurisé et Fiable</h3>
<p class="feature-description">Safe payments, verified venues, and trusted community with comprehensive insurance coverage.</p> <p class="feature-description">Paiements sécurisés, lieux vérifiés et communauté de confiance avec couverture d'assurance complète.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon"> <div class="feature-icon">
<i data-lucide="users-2"></i> <i data-lucide="users-2"></i>
</div> </div>
<h3 class="feature-title">Quality Networking</h3> <h3 class="feature-title">Réseautage de Qualité</h3>
<p class="feature-description">Connect with verified professionals, entrepreneurs, and industry leaders in intimate settings.</p> <p class="feature-description">Connectez-vous avec des professionnels vérifiés, des entrepreneurs et des leaders de l'industrie dans des environnements intimes.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon"> <div class="feature-icon">
<i data-lucide="zap"></i> <i data-lucide="zap"></i>
</div> </div>
<h3 class="feature-title">Instant Booking</h3> <h3 class="feature-title">Réservation Instantanée</h3>
<p class="feature-description">Seamless reservation process with instant confirmation and easy event management.</p> <p class="feature-description">Processus de réservation fluide avec confirmation instantanée et gestion d'événement facile.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -118,19 +118,19 @@
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate"> <div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate">
<span class="stat-number" data-target-value="150">0</span> <span class="stat-number" data-target-value="150">0</span>
<div class="stat-label">Monthly Events</div> <div class="stat-label">Événements Mensuels</div>
</div> </div>
<div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate"> <div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate">
<span class="stat-number" data-target-value="5200">0</span> <span class="stat-number" data-target-value="5200">0</span>
<div class="stat-label">Active Members</div> <div class="stat-label">Membres Actifs</div>
</div> </div>
<div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate"> <div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate">
<span class="stat-number" data-target-value="200">0</span> <span class="stat-number" data-target-value="200">0</span>
<div class="stat-label">Partner Venues</div> <div class="stat-label">Lieux Partenaires</div>
</div> </div>
<div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate"> <div class="stat-item" data-controller="counter" data-action="counter:scroll->counter#animate">
<span class="stat-number" data-target-value="98">0</span> <span class="stat-number" data-target-value="98">0</span>
<div class="stat-label">Satisfaction Rate</div> <div class="stat-label">Taux de Satisfaction</div>
</div> </div>
</div> </div>
</div> </div>
@@ -140,17 +140,13 @@
<section class="cta-section"> <section class="cta-section">
<div class="container"> <div class="container">
<div class="cta-content"> <div class="cta-content">
<h2>Ready to Join the Community?</h2> <h2>Prêt à rejoindre la communauté ?</h2>
<p>Start discovering amazing events and connect with like-minded professionals in your city.</p> <p>Commencez à découvrir des événements incroyables et connectez-vous avec des professionnels partageant les mêmes idées dans votre ville.</p>
<div style="display: flex; gap: var(--space-4); justify-content: center; flex-wrap: wrap;"> <div style="display: flex; gap: var(--space-4); justify-content: center; flex-wrap: wrap;">
<button class="btn btn-lg" style="background: white; color: var(--color-primary-600); border: 2px solid white;"> <%= link_to new_user_registration_path, class: "btn btn-lg bg-white border-2 border-white text-blue-600 hover:bg-blue-400 hover:text-white" do %>
<i data-lucide="user-plus"></i> <i data-lucide="user-plus"></i>
Join Now - Free Rejoindre gratuitement
</button> <% end %>
<button class="btn btn-lg btn-ghost" style="border: 2px solid rgba(255,255,255,0.5); color: white;">
<i data-lucide="calendar"></i>
Browse Events
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,184 @@
<% content_for(:title, "Modifier #{@event.name}") %>
<div class="container py-8">
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Modifier l'événement</h1>
<p class="text-gray-600"><%= @event.name %></p>
</div>
</div>
</div>
<%= form_with model: [:promoter, @event], local: true, class: "space-y-8", data: { controller: "event-form" } do |form| %>
<% if @event.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(@event.errors.count, "erreur") %> à corriger :
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @event.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Basic Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
</div>
<div>
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %>
<p class="mt-1 text-sm text-gray-500">
<% if @event.published? %>
<i data-lucide="alert-triangle" class="w-4 h-4 inline text-yellow-500"></i>
Attention: Modifier le slug d'un événement publié peut casser les liens existants.
<% else %>
Utilisé dans l'URL de l'événement
<% end %>
</p>
</div>
</div>
<div class="mt-6">
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_area :description, rows: 4, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Décrivez votre événement..." %>
</div>
<div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
</div>
</div>
<!-- Date & Time -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Date et heure</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :start_time, "Date et heure de début", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :start_time,
value: @event.start_time&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
<div>
<%= form.label :end_time, "Date et heure de fin", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :end_time,
value: @event.end_time&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
</div>
<% if @event.published? && @event.tickets.any? %>
<div class="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex">
<i data-lucide="alert-triangle" class="w-5 h-5 text-yellow-400 mt-0.5 mr-2"></i>
<p class="text-sm text-yellow-800">
Des billets ont déjà été vendus pour cet événement. Modifier la date pourrait impacter les participants.
</p>
</div>
</div>
<% end %>
</div>
<!-- Venue Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Lieu de l'événement</h3>
<div class="space-y-6">
<div>
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex" %>
</div>
<div>
<%= form.label :venue_address, "Adresse", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :venue_address, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris" %>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :latitude, "Latitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :latitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "48.8566" %>
</div>
<div>
<%= form.label :longitude, "Longitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :longitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "2.3522" %>
</div>
</div>
<p class="text-sm text-gray-500">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
Utilisez un service comme <a href="https://www.latlong.net/" target="_blank" class="text-purple-600 hover:text-purple-800">latlong.net</a> pour obtenir les coordonnées GPS.
</p>
</div>
<% if @event.published? && @event.tickets.any? %>
<div class="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex">
<i data-lucide="alert-triangle" class="w-5 h-5 text-yellow-400 mt-0.5 mr-2"></i>
<p class="text-sm text-yellow-800">
Des billets ont déjà été vendus pour cet événement. Modifier le lieu pourrait impacter les participants.
</p>
</div>
</div>
<% end %>
</div>
<!-- Options -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Options</h3>
<div class="flex items-center">
<%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %>
<%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %>
</div>
<p class="mt-2 text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_path(@event), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler
<% end %>
<% if @event.published? && @event.tickets.any? %>
<p class="text-sm text-yellow-600">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
<%= @event.tickets.count %> billet(s) déjà vendu(s)
</p>
<% end %>
</div>
<div class="flex items-center space-x-3">
<%= form.submit "Sauvegarder les modifications", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,136 @@
<% content_for(:title, "Mes événements") %>
<div class="container py-8">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Mes événements</h1>
<p class="text-gray-600">Gérez tous vos événements depuis cette interface</p>
</div>
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-6 py-3 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer un événement
<% end %>
</div>
<% if @events.any? %>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Événement</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lieu</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<% @events.each do |event| %>
<tr class="hover:bg-gray-50 transition-colors duration-150">
<td class="px-6 py-4">
<div class="flex items-center">
<div class="h-12 w-12 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center flex-shrink-0">
<i data-lucide="calendar" class="w-6 h-6 text-white"></i>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">
<%= link_to event.name, promoter_event_path(event), class: "hover:text-purple-600 transition-colors" %>
</div>
<div class="text-sm text-gray-500 truncate max-w-xs">
<%= event.description.truncate(60) %>
</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<% case event.state %>
<% when "draft" %>
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
<i data-lucide="edit-3" class="w-3 h-3 mr-1"></i>
Brouillon
</span>
<% when "published" %>
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
<i data-lucide="eye" class="w-3 h-3 mr-1"></i>
Publié
</span>
<% when "canceled" %>
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
Annulé
</span>
<% when "sold_out" %>
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
Complet
</span>
<% end %>
<% if event.featured? %>
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 ml-1">
<i data-lucide="star" class="w-3 h-3 mr-1"></i>
À la une
</span>
<% end %>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<% if event.start_time %>
<div><%= event.start_time.strftime("%d/%m/%Y") %></div>
<div class="text-xs text-gray-400"><%= event.start_time.strftime("%H:%M") %></div>
<% else %>
<span class="text-gray-400">Date non définie</span>
<% end %>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div><%= event.venue_name %></div>
<div class="text-xs text-gray-400 truncate max-w-xs"><%= event.venue_address %></div>
</td>
<td class="px-6 py-4">
<div class="flex items-center space-x-2">
<%= link_to promoter_event_path(event), class: "text-gray-400 hover:text-gray-600 transition-colors", title: "Voir" do %>
<i data-lucide="eye" class="w-4 h-4"></i>
<% end %>
<%= link_to edit_promoter_event_path(event), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
<i data-lucide="edit" class="w-4 h-4"></i>
<% end %>
<% if event.draft? %>
<%= button_to publish_promoter_event_path(event), method: :patch, class: "text-gray-400 hover:text-green-600 transition-colors", title: "Publier" do %>
<i data-lucide="upload" class="w-4 h-4"></i>
<% end %>
<% elsif event.published? %>
<%= button_to unpublish_promoter_event_path(event), method: :patch, class: "text-gray-400 hover:text-yellow-600 transition-colors", title: "Dépublier" do %>
<i data-lucide="download" class="w-4 h-4"></i>
<% end %>
<% end %>
<%= button_to promoter_event_path(event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ?" },
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
<i data-lucide="trash-2" class="w-4 h-4"></i>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<div class="mt-6">
<%= paginate @events if respond_to?(:paginate) %>
</div>
<% else %>
<div class="bg-white rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<div class="mx-auto h-24 w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
<i data-lucide="calendar-plus" class="w-12 h-12 text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun événement</h3>
<p class="text-gray-500 mb-6">Vous n'avez pas encore créé d'événement. Commencez dès maintenant !</p>
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-6 py-3 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer mon premier événement
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,146 @@
<% content_for(:title, "Créer un événement") %>
<div class="container py-8">
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4">
<%= link_to promoter_events_path, class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Créer un événement</h1>
<p class="text-gray-600">Remplissez les informations de votre événement</p>
</div>
</div>
</div>
<%= form_with model: [:promoter, @event], local: true, class: "space-y-8", data: { controller: "event-form" } do |form| %>
<% if @event.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(@event.errors.count, "erreur") %> à corriger :
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @event.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Basic Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
</div>
<div>
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %>
<p class="mt-1 text-sm text-gray-500">Utilisé dans l'URL de l'événement</p>
</div>
</div>
<div class="mt-6">
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_area :description, rows: 4, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Décrivez votre événement..." %>
</div>
<div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
</div>
</div>
<!-- Date & Time -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Date et heure</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :start_time, "Date et heure de début", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :start_time, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
<div>
<%= form.label :end_time, "Date et heure de fin", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :end_time, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
</div>
</div>
<!-- Venue Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Lieu de l'événement</h3>
<div class="space-y-6">
<div>
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex" %>
</div>
<div>
<%= form.label :venue_address, "Adresse", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :venue_address, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris" %>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :latitude, "Latitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :latitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "48.8566" %>
</div>
<div>
<%= form.label :longitude, "Longitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :longitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "2.3522" %>
</div>
</div>
<p class="text-sm text-gray-500">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
Utilisez un service comme <a href="https://www.latlong.net/" target="_blank" class="text-purple-600 hover:text-purple-800">latlong.net</a> pour obtenir les coordonnées GPS.
</p>
</div>
</div>
<!-- Options -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Options</h3>
<div class="flex items-center">
<%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %>
<%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %>
</div>
<p class="mt-2 text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_events_path, class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler
<% end %>
</div>
<div class="flex items-center space-x-3">
<%= form.submit "Créer en brouillon", class: "inline-flex items-center px-6 py-3 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors duration-200" %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,231 @@
<% content_for(:title, @event.name) %>
<div class="container py-8">
<!-- Header with actions -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_events_path, class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2"><%= @event.name %></h1>
<div class="flex items-center space-x-4 text-sm text-gray-500">
<span class="flex items-center">
<i data-lucide="calendar" class="w-4 h-4 mr-1"></i>
<%= @event.start_time&.strftime("%d/%m/%Y à %H:%M") || "Date non définie" %>
</span>
<span class="flex items-center">
<i data-lucide="map-pin" class="w-4 h-4 mr-1"></i>
<%= @event.venue_name %>
</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<%= link_to edit_promoter_event_path(@event), class: "inline-flex items-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier
<% end %>
<% if @event.draft? %>
<%= button_to publish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
Publier
<% end %>
<% elsif @event.published? %>
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-yellow-600 text-white font-medium rounded-lg hover:bg-yellow-700 transition-colors duration-200" do %>
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
Dépublier
<% end %>
<% end %>
<% if @event.published? %>
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors duration-200", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %>
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
Annuler
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Status banner -->
<div class="mb-8">
<% case @event.state %>
<% when "draft" %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="edit-3" class="w-5 h-5 text-gray-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-gray-900">Événement en brouillon</h3>
<p class="text-sm text-gray-500">Cet événement n'est pas visible publiquement. Publiez-le pour le rendre accessible aux utilisateurs.</p>
</div>
</div>
</div>
<% when "published" %>
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="eye" class="w-5 h-5 text-green-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-green-900">Événement publié</h3>
<p class="text-sm text-green-700">Cet événement est visible publiquement et les utilisateurs peuvent acheter des billets.</p>
</div>
<div class="ml-auto">
<%= link_to event_path(@event), target: "_blank", class: "text-green-600 hover:text-green-800 font-medium text-sm" do %>
Voir publiquement <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
<% end %>
</div>
</div>
</div>
<% when "canceled" %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="x-circle" class="w-5 h-5 text-red-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-red-900">Événement annulé</h3>
<p class="text-sm text-red-700">Cet événement a été annulé et n'est plus accessible aux utilisateurs.</p>
</div>
</div>
</div>
<% when "sold_out" %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="users" class="w-5 h-5 text-blue-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-blue-900">Événement complet</h3>
<p class="text-sm text-blue-700">Tous les billets pour cet événement ont été vendus.</p>
</div>
</div>
</div>
<% end %>
<% if @event.featured? %>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
<div class="flex items-center">
<i data-lucide="star" class="w-5 h-5 text-yellow-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-yellow-900">Événement à la une</h3>
<p class="text-sm text-yellow-700">Cet événement est mis en avant sur la page d'accueil.</p>
</div>
</div>
</div>
<% end %>
</div>
<!-- Event details -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main content -->
<div class="lg:col-span-2 space-y-8">
<!-- Event image -->
<% if @event.image.present? %>
<div class="aspect-video bg-gray-100 rounded-lg overflow-hidden">
<img src="<%= @event.image %>" alt="<%= @event.name %>" class="w-full h-full object-cover">
</div>
<% end %>
<!-- Description -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Description</h3>
<div class="prose prose-gray max-w-none">
<%= simple_format(@event.description) %>
</div>
</div>
<!-- Location details -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Lieu</h3>
<div class="space-y-3">
<div class="flex items-start space-x-3">
<i data-lucide="building" class="w-5 h-5 text-gray-400 mt-0.5"></i>
<div>
<p class="font-medium text-gray-900"><%= @event.venue_name %></p>
<p class="text-gray-500"><%= @event.venue_address %></p>
</div>
</div>
<div class="flex items-center space-x-3 text-sm text-gray-500">
<i data-lucide="map-pin" class="w-4 h-4"></i>
<span><%= @event.latitude %>, <%= @event.longitude %></span>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Event stats -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-500">Types de billets</span>
<span class="font-medium"><%= @event.ticket_types.count %></span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500">Billets vendus</span>
<span class="font-medium"><%= @event.tickets.count %></span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500">Revenus</span>
<span class="font-medium">
<%= number_to_currency(@event.tickets.sum(:price_cents) / 100.0, unit: "€") %>
</span>
</div>
</div>
</div>
<!-- Event info -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Informations</h3>
<div class="space-y-4">
<div>
<span class="text-sm text-gray-500">Slug</span>
<p class="font-mono text-sm"><%= @event.slug %></p>
</div>
<div>
<span class="text-sm text-gray-500">Créé le</span>
<p class="text-sm"><%= @event.created_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
<div>
<span class="text-sm text-gray-500">Modifié le</span>
<p class="text-sm"><%= @event.updated_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
<% if @event.start_time %>
<div>
<span class="text-sm text-gray-500">Début</span>
<p class="text-sm"><%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
<% end %>
<% if @event.end_time %>
<div>
<span class="text-sm text-gray-500">Fin</span>
<p class="text-sm"><%= @event.end_time.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
<% end %>
</div>
</div>
<!-- Quick actions -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
<div class="space-y-3">
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center px-4 py-2 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
Gérer les types de billets
<% end %>
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center px-4 py-2 bg-gray-50 text-gray-700 font-medium rounded-lg hover:bg-gray-100 transition-colors duration-200", disabled: !@event.published? do %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
Marquer comme complet
<% end %>
<hr class="border-gray-200">
<%= button_to promoter_event_path(@event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer l'événement
<% end %>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,224 @@
<% content_for(:title, "Modifier #{@ticket_type.name}") %>
<div class="container py-8">
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Modifier le type de billet</h1>
<p class="text-gray-600"><%= @ticket_type.name %></p>
</div>
</div>
</div>
<%= form_with model: [:promoter, @event, @ticket_type], local: true, class: "space-y-8", data: { controller: "ticket-type-form" } do |form| %>
<% if @ticket_type.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(@ticket_type.errors.count, "erreur") %> à corriger :
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @ticket_type.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Warning if tickets sold -->
<% if @ticket_type.tickets.any? %>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex">
<i data-lucide="alert-triangle" class="w-5 h-5 text-yellow-400 mt-0.5 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-yellow-900">Attention</h3>
<p class="text-sm text-yellow-800 mt-1">
<%= pluralize(@ticket_type.tickets.count, 'billet') %> de ce type ont déjà été vendus.
Modifier certains paramètres pourrait impacter les acheteurs existants.
</p>
</div>
</div>
</div>
<% end %>
<!-- Basic Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="space-y-6">
<div>
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Early Bird, VIP, Standard" %>
</div>
<div>
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_area :description, rows: 3, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Décrivez ce qui est inclus dans ce type de billet..." %>
</div>
</div>
</div>
<!-- Pricing & Quantity -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Prix et quantité</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="relative">
<%= form.number_field :price_euros,
step: 0.01,
min: 0.01,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
</div>
<% if @ticket_type.tickets.any? %>
<p class="mt-1 text-sm text-yellow-600">
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i>
Modifier le prix n'affectera pas les billets déjà vendus
</p>
<% end %>
</div>
<div>
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :quantity,
min: @ticket_type.tickets.count,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
<% if @ticket_type.tickets.any? %>
<p class="mt-1 text-sm text-gray-500">
Minimum: <%= @ticket_type.tickets.count %> (billets déjà vendus)
</p>
<% else %>
<p class="mt-1 text-sm text-gray-500">Nombre total de billets de ce type</p>
<% end %>
</div>
</div>
<!-- Revenue preview -->
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="p-4 bg-purple-50 rounded-lg border border-purple-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-purple-900">Revenus potentiels restants</span>
<span class="text-lg font-bold text-purple-600" data-ticket-type-form-target="total">
<%= number_to_currency(@ticket_type.remaining_potential_revenue, unit: "€") %>
</span>
</div>
</div>
<div class="p-4 bg-green-50 rounded-lg border border-green-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-green-900">Revenus déjà générés</span>
<span class="text-lg font-bold text-green-600">
<%= number_to_currency(@ticket_type.current_revenue, unit: "€") %>
</span>
</div>
</div>
</div>
</div>
<!-- Sales Period -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Période de vente</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_start_at,
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
<% if @ticket_type.tickets.any? %>
<p class="mt-1 text-sm text-yellow-600">
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i>
Des ventes ont déjà eu lieu
</p>
<% end %>
</div>
<div>
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_end_at,
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
</div>
<% if @event.start_time %>
<div class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
Les ventes doivent se terminer avant le début de l'événement.
</p>
</div>
</div>
<% end %>
</div>
<!-- Access Requirements -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Conditions d'accès</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :minimum_age,
min: 0,
max: 120,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
placeholder: "Laisser vide si aucune restriction" %>
</div>
</div>
<div class="mt-6">
<div class="flex items-start">
<%= form.check_box :requires_id, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-1" %>
<div class="ml-3">
<%= form.label :requires_id, "Vérification d'identité requise", class: "text-sm font-medium text-gray-700" %>
<p class="text-sm text-gray-500 mt-1">
Cochez si une pièce d'identité sera vérifiée à l'entrée.
<% if @ticket_type.tickets.any? && @ticket_type.requires_id != params.dig(:ticket_type, :requires_id) %>
<br><span class="text-yellow-600">Attention: Cette modification affectera l'expérience des acheteurs existants.</span>
<% end %>
</p>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler
<% end %>
<% if @ticket_type.tickets.any? %>
<p class="text-sm text-yellow-600">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
<%= pluralize(@ticket_type.tickets.count, 'billet') %> déjà vendu(s)
</p>
<% end %>
</div>
<div class="flex items-center space-x-3">
<%= form.submit "Sauvegarder les modifications", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,170 @@
<% content_for(:title, "Types de billets - #{@event.name}") %>
<div class="container py-8">
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Types de billets</h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
<%= link_to new_promoter_event_ticket_type_path(@event), class: "inline-flex items-center px-6 py-3 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Nouveau type
<% end %>
</div>
<!-- Event status info -->
<% if @event.draft? %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<i data-lucide="info" class="w-5 h-5 text-gray-400 mr-3"></i>
<p class="text-sm text-gray-600">
Cet événement est en brouillon. Les types de billets ne seront visibles qu'une fois l'événement publié.
</p>
</div>
</div>
<% end %>
</div>
<% if @ticket_types.any? %>
<div class="grid gap-6">
<% @ticket_types.each do |ticket_type| %>
<div class="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-md transition-shadow duration-200">
<div class="flex items-start justify-between">
<!-- Ticket type info -->
<div class="flex-1">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
<%= link_to ticket_type.name, promoter_event_ticket_type_path(@event, ticket_type), class: "hover:text-purple-600 transition-colors" %>
</h3>
<p class="text-gray-600 mb-3"><%= ticket_type.description %></p>
</div>
<!-- Status badge -->
<div class="ml-4">
<% case ticket_type.sales_status %>
<% when :available %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
En vente
</span>
<% when :upcoming %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-blue-100 text-blue-800">
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
Prochainement
</span>
<% when :sold_out %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
<i data-lucide="users" class="w-4 h-4 mr-1"></i>
Épuisé
</span>
<% when :expired %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
Expiré
</span>
<% end %>
</div>
</div>
<!-- Ticket details grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-purple-600">
<%= number_to_currency(ticket_type.price_euros, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Prix</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= ticket_type.available_quantity %>/<%= ticket_type.quantity %>
</div>
<div class="text-sm text-gray-500">Disponibles</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= ticket_type.tickets.count %>
</div>
<div class="text-sm text-gray-500">Vendus</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-green-600">
<%= number_to_currency(ticket_type.current_revenue, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Revenus</div>
</div>
</div>
<!-- Additional info -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
<span class="flex items-center">
<i data-lucide="calendar" class="w-4 h-4 mr-1"></i>
Vente: <%= ticket_type.sale_start_at.strftime("%d/%m %H:%M") %> - <%= ticket_type.sale_end_at.strftime("%d/%m %H:%M") %>
</span>
<% if ticket_type.minimum_age %>
<span class="flex items-center">
<i data-lucide="user-check" class="w-4 h-4 mr-1"></i>
Âge min: <%= ticket_type.minimum_age %> ans
</span>
<% end %>
<% if ticket_type.requires_id %>
<span class="flex items-center">
<i data-lucide="id-card" class="w-4 h-4 mr-1"></i>
Pièce d'identité requise
</span>
<% end %>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<%= link_to promoter_event_ticket_type_path(@event, ticket_type), class: "text-gray-400 hover:text-gray-600 transition-colors", title: "Voir" do %>
<i data-lucide="eye" class="w-5 h-5"></i>
<% end %>
<%= link_to edit_promoter_event_ticket_type_path(@event, ticket_type), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
<i data-lucide="edit" class="w-5 h-5"></i>
<% end %>
<%= button_to duplicate_promoter_event_ticket_type_path(@event, ticket_type), method: :post, class: "text-gray-400 hover:text-green-600 transition-colors", title: "Dupliquer" do %>
<i data-lucide="copy" class="w-5 h-5"></i>
<% end %>
<% if ticket_type.tickets.empty? %>
<%= button_to promoter_event_ticket_type_path(@event, ticket_type), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce type de billet ?" },
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
<i data-lucide="trash-2" class="w-5 h-5"></i>
<% end %>
<% end %>
</div>
<div class="text-sm text-gray-500">
Créé <%= time_ago_in_words(ticket_type.created_at) %>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<div class="mx-auto h-24 w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
<i data-lucide="ticket" class="w-12 h-12 text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun type de billet</h3>
<p class="text-gray-500 mb-6">Créez des types de billets pour permettre aux utilisateurs d'acheter des places pour votre événement.</p>
<%= link_to new_promoter_event_ticket_type_path(@event), class: "inline-flex items-center px-6 py-3 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer mon premier type de billet
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,177 @@
<% content_for(:title, "Nouveau type de billet - #{@event.name}") %>
<div class="container py-8">
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_types_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Nouveau type de billet</h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
</div>
<%= form_with model: [:promoter, @event, @ticket_type], local: true, class: "space-y-8", data: { controller: "ticket-type-form" } do |form| %>
<% if @ticket_type.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(@ticket_type.errors.count, "erreur") %> à corriger :
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @ticket_type.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Basic Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="space-y-6">
<div>
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Early Bird, VIP, Standard" %>
<p class="mt-1 text-sm text-gray-500">Nom affiché aux acheteurs</p>
</div>
<div>
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_area :description, rows: 3, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Décrivez ce qui est inclus dans ce type de billet..." %>
<p class="mt-1 text-sm text-gray-500">Description visible lors de l'achat</p>
</div>
</div>
</div>
<!-- Pricing & Quantity -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Prix et quantité</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="relative">
<%= form.number_field :price_euros,
step: 0.01,
min: 0.01,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
</div>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet</p>
</div>
<div>
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :quantity,
min: 1,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
<p class="mt-1 text-sm text-gray-500">Nombre total de billets de ce type</p>
</div>
</div>
<!-- Revenue preview -->
<div class="mt-6 p-4 bg-purple-50 rounded-lg border border-purple-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-purple-900">Revenus potentiels (si tout vendu)</span>
<span class="text-lg font-bold text-purple-600" data-ticket-type-form-target="total">
<%= number_to_currency(0, unit: "€") %>
</span>
</div>
</div>
</div>
<!-- Sales Period -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Période de vente</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_start_at,
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
<div>
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_end_at,
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
<p class="mt-1 text-sm text-gray-500">Les ventes s'arrêtent automatiquement à cette date</p>
</div>
</div>
<% if @event.start_time %>
<div class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
Les ventes doivent se terminer avant le début de l'événement.
</p>
</div>
</div>
<% end %>
</div>
<!-- Access Requirements -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Conditions d'accès</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :minimum_age,
min: 0,
max: 120,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
placeholder: "Laisser vide si aucune restriction" %>
<p class="mt-1 text-sm text-gray-500">Âge minimum requis (optionnel)</p>
</div>
</div>
<div class="mt-6">
<div class="flex items-start">
<%= form.check_box :requires_id, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-1" %>
<div class="ml-3">
<%= form.label :requires_id, "Vérification d'identité requise", class: "text-sm font-medium text-gray-700" %>
<p class="text-sm text-gray-500 mt-1">
Cochez si une pièce d'identité sera vérifiée à l'entrée. Les noms des participants seront collectés lors de l'achat.
</p>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_types_path(@event), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler
<% end %>
</div>
<div class="flex items-center space-x-3">
<%= form.submit "Créer le type de billet", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,240 @@
<% content_for(:title, "#{@ticket_type.name} - #{@event.name}") %>
<div class="container py-8">
<!-- Header with actions -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_types_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2"><%= @ticket_type.name %></h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<%= link_to edit_promoter_event_ticket_type_path(@event, @ticket_type), class: "inline-flex items-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier
<% end %>
<%= button_to duplicate_promoter_event_ticket_type_path(@event, @ticket_type), method: :post, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
Dupliquer
<% end %>
</div>
</div>
</div>
<!-- Status banner -->
<div class="mb-8">
<% case @ticket_type.sales_status %>
<% when :available %>
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="check-circle" class="w-5 h-5 text-green-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-green-900">Type de billet en vente</h3>
<p class="text-sm text-green-700">Ce type de billet est actuellement disponible à l'achat.</p>
</div>
</div>
</div>
<% when :upcoming %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="clock" class="w-5 h-5 text-blue-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-blue-900">Ventes à venir</h3>
<p class="text-sm text-blue-700">Les ventes commenceront le <%= @ticket_type.sale_start_at.strftime("%d/%m/%Y à %H:%M") %>.</p>
</div>
</div>
</div>
<% when :sold_out %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="users" class="w-5 h-5 text-red-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-red-900">Type de billet épuisé</h3>
<p class="text-sm text-red-700">Tous les billets de ce type ont été vendus.</p>
</div>
</div>
</div>
<% when :expired %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="x-circle" class="w-5 h-5 text-gray-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-gray-900">Ventes terminées</h3>
<p class="text-sm text-gray-700">La période de vente pour ce type de billet est terminée.</p>
</div>
</div>
</div>
<% end %>
</div>
<!-- Ticket details -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main content -->
<div class="lg:col-span-2 space-y-8">
<!-- Description -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Description</h3>
<p class="text-gray-700 leading-relaxed"><%= simple_format(@ticket_type.description) %></p>
</div>
<!-- Sales Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Période de vente</h3>
<div class="space-y-4">
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<span class="text-gray-600">Début des ventes</span>
<span class="font-medium"><%= @ticket_type.sale_start_at.strftime("%d/%m/%Y à %H:%M") %></span>
</div>
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<span class="text-gray-600">Fin des ventes</span>
<span class="font-medium"><%= @ticket_type.sale_end_at.strftime("%d/%m/%Y à %H:%M") %></span>
</div>
<% if @ticket_type.minimum_age %>
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<span class="text-gray-600">Âge minimum</span>
<span class="font-medium"><%= @ticket_type.minimum_age %> ans</span>
</div>
<% end %>
<div class="flex items-center justify-between py-3">
<span class="text-gray-600">Vérification d'identité</span>
<span class="font-medium">
<% if @ticket_type.requires_id %>
<span class="text-green-600">Requise</span>
<% else %>
<span class="text-gray-500">Non requise</span>
<% end %>
</span>
</div>
</div>
</div>
<!-- Buyers List (if any) -->
<% if @ticket_type.tickets.any? %>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Acheteurs récents</h3>
<div class="space-y-3">
<% @ticket_type.tickets.includes(:user).order(created_at: :desc).limit(10).each do |ticket| %>
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
<div>
<p class="font-medium text-gray-900"><%= ticket.first_name %> <%= ticket.last_name %></p>
<p class="text-sm text-gray-500"><%= ticket.user.email %></p>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-900">
<%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
</p>
<p class="text-xs text-gray-500">
<%= ticket.created_at.strftime("%d/%m/%Y") %>
</p>
</div>
</div>
<% end %>
<% if @ticket_type.tickets.count > 10 %>
<p class="text-sm text-gray-500 text-center pt-2">
Et <%= @ticket_type.tickets.count - 10 %> autre(s) acheteur(s)...
</p>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Statistics -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
<div class="space-y-4">
<div class="text-center p-4 bg-purple-50 rounded-lg">
<div class="text-3xl font-bold text-purple-600">
<%= number_to_currency(@ticket_type.price_euros, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Prix unitaire</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= @ticket_type.tickets.count %>
</div>
<div class="text-xs text-gray-500">Vendus</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= @ticket_type.available_quantity %>
</div>
<div class="text-xs text-gray-500">Restants</div>
</div>
</div>
<div class="text-center p-4 bg-green-50 rounded-lg">
<div class="text-2xl font-bold text-green-600">
<%= number_to_currency(@ticket_type.current_revenue, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Revenus générés</div>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg">
<div class="text-2xl font-bold text-blue-600">
<%= number_to_currency(@ticket_type.total_potential_revenue, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Potentiel total</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
<div class="space-y-3">
<%= link_to edit_promoter_event_ticket_type_path(@event, @ticket_type), class: "w-full inline-flex items-center px-4 py-2 bg-gray-50 text-gray-700 font-medium rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier les détails
<% end %>
<%= button_to duplicate_promoter_event_ticket_type_path(@event, @ticket_type), method: :post, class: "w-full inline-flex items-center px-4 py-2 bg-gray-50 text-gray-700 font-medium rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
Créer une copie
<% end %>
<hr class="border-gray-200">
<% if @ticket_type.tickets.empty? %>
<%= button_to promoter_event_ticket_type_path(@event, @ticket_type), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce type de billet ? Cette action est irréversible." },
class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer le type
<% end %>
<% else %>
<div class="w-full inline-flex items-center px-4 py-2 text-gray-400 font-medium rounded-lg cursor-not-allowed">
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Impossible de supprimer
</div>
<p class="text-xs text-gray-500">Des billets ont été vendus</p>
<% end %>
</div>
</div>
<!-- Creation info -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Informations</h3>
<div class="space-y-3 text-sm">
<div>
<span class="text-gray-500">Créé le</span>
<p><%= @ticket_type.created_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
<div>
<span class="text-gray-500">Dernière modification</span>
<p><%= @ticket_type.updated_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ticket #<%= ticket.id %></title>
<style>
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
color: #000000;
margin: 0;
padding: 20px;
background-color: #ffffff;
}
.ticket-container {
max-width: 350px;
margin: 0 auto;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background-color: #ffffff;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
color: #2D1B69;
font-size: 24px;
font-weight: bold;
margin: 0;
}
.event-name {
text-align: center;
margin-bottom: 20px;
}
.event-name h2 {
color: #000000;
font-size: 18px;
font-weight: bold;
margin: 0;
}
.ticket-info {
margin-bottom: 20px;
}
.info-row {
margin-bottom: 8px;
font-size: 14px;
}
.qr-code-section {
text-align: center;
margin-top: 20px;
}
.qr-code-container svg {
width: 120px;
height: 120px;
}
</style>
</head>
<body>
<div class="ticket-container">
<div class="header">
<h1>ApéroNight</h1>
</div>
<div class="event-name">
<h2><%= ticket.event.name %></h2>
</div>
<div class="ticket-info">
<div class="info-row">
<strong>Ticket Holder:</strong> <%= ticket.first_name %> <%= ticket.last_name %>
</div>
<div class="info-row">
<strong>Ticket Type:</strong> <%= ticket.ticket_type.name %>
</div>
<div class="info-row">
<strong>Price:</strong> €<%= ticket.price_euros %>
</div>
</div>
<div class="qr-code-section">
<div class="qr-code-container">
<%= raw ticket.generate_qr_svg %>
</div>
</div>
</div>
</body>
</html>

View File

@@ -18,7 +18,7 @@
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg> </svg>
<%= link_to event_path(@event.slug, @event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %> <%= link_to event_path(@event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<%= @event.name %> <%= @event.name %>
<% end %> <% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">

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