1 Commits

Author SHA1 Message Date
kbe
bf035de402 style(): Improve how events are displayed.
- No more user name on card
- Bigger date
2025-08-28 18:22:36 +02:00
212 changed files with 13573 additions and 19700 deletions

View File

@@ -1,18 +1,18 @@
# Application data # Application data
RAILS_ENV=production RAILS_ENV=development
SECRET_KEY_BASE=a3f5c6e7b8d9e0f1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7 SECRET_KEY_BASE=a3f5c6e7b8d9e0f1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7
DEVISE_SECRET_KEY=your_devise_secret_key_here DEVISE_SECRET_KEY=your_devise_secret_key_here
APP_NAME=Aperonight APP_NAME=Aperonight
# Database Configuration for production and development # Database Configuration for production and development
# DB_HOST=127.0.0.1 DB_HOST=localhost
# DB_PORT=3306
DB_ROOT_PASSWORD=root DB_ROOT_PASSWORD=root
DB_DATABASE=aperonight DB_DATABASE=aperonight
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=root DB_PASSWORD=root
# Test database # Test database
DB_TEST_ADAPTER=sqlite3
DB_TEST_DATABASE=aperonight_test DB_TEST_DATABASE=aperonight_test
DB_TEST_USERNAME=root DB_TEST_USERNAME=root
DB_TEST_USERNAME=root DB_TEST_USERNAME=root
@@ -28,24 +28,20 @@ SMTP_PORT=1025
# SMTP_DOMAIN=localhost # SMTP_DOMAIN=localhost
SMTP_AUTHENTICATION=plain SMTP_AUTHENTICATION=plain
SMTP_ENABLE_STARTTLS=false SMTP_ENABLE_STARTTLS=false
# Production SMTP Configuration (set these in .env.production)
# SMTP_ADDRESS=smtp.example.com
# SMTP_PORT=587
# SMTP_USERNAME=your_smtp_username
# SMTP_PASSWORD=your_smtp_password
# SMTP_AUTHENTICATION=plain
# SMTP_DOMAIN=example.com
# SMTP_STARTTLS=true # SMTP_STARTTLS=true
# Invoice Emitter Configuration
INVOICE_COMPANY_NAME=AperoNight
INVOICE_COMPANY_ADDRESS_LINE_1=123 Avenue des Événements
INVOICE_COMPANY_ADDRESS_LINE_2=75000 Paris, France
INVOICE_COMPANY_EMAIL=contact@apero-night.fr
INVOICE_COMPANY_PHONE=
INVOICE_COMPANY_WEBSITE=
INVOICE_COMPANY_VAT_NUMBER=
INVOICE_COMPANY_SIRET=
# Application variables # Application variables
STRIPE_PUBLISHABLE_KEY=pk_test_51S1M7BJWx6G2LLIXYpTvi0hxMpZ4tZSxkmr2Wbp1dQ73MKNp4Tyu4xFJBqLXK5nn4E0nEf2tdgJqEwWZLosO3QGn00kMvjXWGW STRIPE_API_KEY=1337
STRIPE_SECRET_KEY=sk_test_51S1M7BJWx6G2LLIXK2pdLpRKb9Mgd3sZ30N4ueVjHepgxQKbWgMVJoa4v4ESzHQ6u6zJjO4jUvgLYPU1QLyAiFTN00sGz2ortW
STRIPE_WEBHOOK_SECRET=LaReunion974
# Scaleway login # OpenAI login
OPENAI_API_KEY=f66dbb5f-9770-4f81-b2ea-eb7370bc9aa5 OPENAI_API_KEY=f66dbb5f-9770-4f81-b2ea-eb7370bc9aa5
OPENAI_BASE_URL=https://api.scaleway.ai/v1 OPENAI_BASE_URL=https://api.scaleway.ai/v1
OPENAI_MODEL=devstral-small-2505 OPENAI_MODEL=devstral-small-2505

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,618 @@
/* 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,627 @@
<!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

@@ -0,0 +1,417 @@
/* 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

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

766
CLAUDE.md Executable file
View File

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

51
CRUSH.md Executable file
View File

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

View File

@@ -57,9 +57,6 @@ group :development, :test do
# Improve Minitest output # Improve Minitest output
gem "minitest-reporters", "~> 1.7" gem "minitest-reporters", "~> 1.7"
# Load environment variables from .env file
gem "dotenv-rails"
end end
group :development do group :development do
@@ -71,11 +68,6 @@ 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"
gem "timecop"
end end
gem "devise", "~> 4.9" gem "devise", "~> 4.9"

View File

@@ -113,9 +113,6 @@ GEM
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
dotenv (3.1.8) dotenv (3.1.8)
dotenv-rails (3.1.8)
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (5.0.2) erb (5.0.2)
@@ -184,8 +181,6 @@ 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)
@@ -211,8 +206,6 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-arm-linux-musl) nokogiri (1.18.9-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu) nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl) nokogiri (1.18.9-x86_64-linux-musl)
@@ -269,10 +262,6 @@ 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
@@ -333,7 +322,6 @@ 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)
@@ -362,7 +350,6 @@ GEM
sqlite3 (2.7.3-aarch64-linux-musl) sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu) sqlite3 (2.7.3-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl) sqlite3 (2.7.3-arm-linux-musl)
sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu) sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl) sqlite3 (2.7.3-x86_64-linux-musl)
sshkit (1.24.0) sshkit (1.24.0)
@@ -379,9 +366,7 @@ GEM
thor (1.4.0) thor (1.4.0)
thruster (0.1.15) thruster (0.1.15)
thruster (0.1.15-aarch64-linux) thruster (0.1.15-aarch64-linux)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux) thruster (0.1.15-x86_64-linux)
timecop (0.9.10)
timeout (0.4.3) timeout (0.4.3)
ttfunk (1.8.0) ttfunk (1.8.0)
bigdecimal (~> 3.1) bigdecimal (~> 3.1)
@@ -417,7 +402,6 @@ PLATFORMS
aarch64-linux-musl aarch64-linux-musl
arm-linux-gnu arm-linux-gnu
arm-linux-musl arm-linux-musl
x86_64-darwin-24
x86_64-linux-gnu x86_64-linux-gnu
x86_64-linux-musl x86_64-linux-musl
@@ -428,21 +412,18 @@ DEPENDENCIES
cssbundling-rails cssbundling-rails
debug debug
devise (~> 4.9) devise (~> 4.9)
dotenv-rails
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 (~> 2.5)
prawn-qrcode (~> 0.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
@@ -453,7 +434,6 @@ DEPENDENCIES
stimulus-rails stimulus-rails
stripe (~> 15.5) stripe (~> 15.5)
thruster thruster
timecop
turbo-rails turbo-rails
tzinfo-data tzinfo-data
web-console web-console

25
QWEN.md Executable file
View File

@@ -0,0 +1,25 @@
# 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

View File

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

View File

@@ -9,7 +9,44 @@
/* 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 */
body {
font-family: var(--font-sans);
line-height: 1.6;
color: var(--color-neutral-900);
background: var(--color-neutral-50);
}
/* App wrapper */
.app-wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 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

@@ -0,0 +1,82 @@
.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

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

View File

@@ -1,90 +0,0 @@
/* Events page specific styles */
.events-page {
background: linear-gradient(135deg, var(--color-neutral-50) 0%, var(--color-neutral-100) 100%);
min-height: 100vh;
}
.events-page .breadcrumb {
padding: var(--space-4) 0;
}
.events-page .event-card {
background: white;
border-radius: var(--radius-2xl);
overflow: hidden;
box-shadow: var(--shadow-lg);
transition: all var(--duration-slow) var(--ease-out);
border: 1px solid var(--color-neutral-200);
position: relative;
}
.events-page .event-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: var(--shadow-2xl);
border-color: var(--color-primary-200);
}
.events-page .event-date-badge {
background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-accent-100) 100%);
color: var(--color-primary-800);
font-weight: 700;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
}
.events-page .price-highlight {
color: var(--color-primary-600);
font-weight: 800;
}
.events-page .pagination {
margin-top: var(--space-12);
}
.events-page .pagination .page,
.events-page .pagination .next,
.events-page .pagination .last,
.events-page .pagination .prev,
.events-page .pagination .first {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-lg);
margin: 0 var(--space-1);
transition: all var(--duration-normal);
}
.events-page .pagination .page:hover,
.events-page .pagination .next:hover,
.events-page .pagination .last:hover,
.events-page .pagination .prev:hover,
.events-page .pagination .first:hover {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.events-page .pagination .current {
background: var(--color-primary-600);
color: white;
font-weight: 700;
}
.events-page .no-events-card {
background: white;
border-radius: var(--radius-2xl);
padding: var(--space-12);
box-shadow: var(--shadow-lg);
text-align: center;
max-width: 500px;
margin: 0 auto;
}
@media (max-width: 768px) {
.events-page .event-grid {
grid-template-columns: 1fr;
}
.events-page .no-events-card {
padding: var(--space-8);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
# Provides authentication and common functionality for API controllers # Provides authentication and common functionality for API controllers
class ApiController < ApplicationController class ApiController < ApplicationController
# Disable CSRF protection for API requests (token-based authentication instead) # Disable CSRF protection for API requests (token-based authentication instead)
protect_from_forgery prepend: true protect_from_forgery with: :null_session
# Authenticate all API requests using API key # Authenticate all API requests using API key
# Must be called before any API action # Must be called before any API action
@@ -16,10 +16,8 @@ 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 environment variable for security # Validate against hardcoded key (in production, use environment variable)
expected_key = Rails.application.credentials.api_key || ENV["API_KEY"] unless api_key == "aperonight-api-key-2025"
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

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

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::ConfirmationsController < Devise::ConfirmationsController class Authentications::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 Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController class Authentications::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 Auth::PasswordsController < Devise::PasswordsController class Authentications::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 Auth::RegistrationsController < Devise::RegistrationsController class Authentications::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 ]
@@ -47,7 +47,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
# If you have extra params to permit, append them to the sanitizer. # If you have extra params to permit, append them to the sanitizer.
def configure_account_update_params def configure_account_update_params
devise_parameter_sanitizer.permit(:account_update, keys: [ :last_name, :first_name, :is_professionnal ]) devise_parameter_sanitizer.permit(:account_update, keys: [ :last_name, :first_name ])
end end
# The path used after sign up. # The path used after sign up.

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController class Authentications::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 Auth::UnlocksController < Devise::UnlocksController class Authentications::UnlocksController < Devise::UnlocksController
# GET /resource/unlock/new # GET /resource/unlock/new
# def new # def new
# super # super

View File

@@ -1,18 +0,0 @@
module StripeConcern
extend ActiveSupport::Concern
# Check if Stripe is properly configured
def stripe_configured?
Rails.application.config.stripe[:secret_key].present?
end
# Stripe is now initialized at application startup, so this method is no longer needed
# but kept for backward compatibility
def initialize_stripe
return false unless stripe_configured?
# Stripe is already initialized at application startup
Rails.logger.debug "Stripe already initialized at application startup"
true
end
end

View File

@@ -1,36 +1,154 @@
# Events controller - Public event listings and individual event display
#
# This controller manages public event browsing and displays individual events
# with their associated ticket types. No authentication required for public browsing.
class EventsController < ApplicationController class EventsController < ApplicationController
# No authentication required for public event viewing before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
before_action :authenticate_user!, only: [] before_action :set_event, only: [:show, :checkout]
before_action :set_event, only: [ :show ]
# Display paginated list of upcoming published events # Display all events
#
# Shows events in published state, ordered by start time ascending
# 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 individual event with ticket type information # Display desired event
#
# 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 with ticket types preloaded # Event is set by set_event callback
# Template will display event details and ticket selection interface end
# Handle checkout process - Create Stripe session
def checkout
cart_data = JSON.parse(params[:cart] || "{}")
if cart_data.empty?
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
return
end
# 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
begin
# Create Stripe Checkout Session
session = Stripe::Checkout::Session.create({
payment_method_types: ['card'],
line_items: line_items,
mode: 'payment',
success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'),
cancel_url: event_url(@event.slug, @event),
customer_email: current_user.email,
metadata: {
event_id: @event.id,
user_id: current_user.id,
order_items: order_items.to_json
}
})
redirect_to session.url, allow_other_host: true
rescue Stripe::StripeError => e
redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}"
end
end
# Handle successful payment
def payment_success
session_id = params[:session_id]
event_id = params[:event_id]
begin
session = Stripe::Checkout::Session.retrieve(session_id)
if session.payment_status == 'paid'
# Create tickets
@event = Event.find(event_id)
order_items = JSON.parse(session.metadata['order_items'])
@tickets = []
order_items.each do |item|
ticket_type = TicketType.find(item['ticket_type_id'])
item['quantity'].times do
ticket = Ticket.create!(
user: current_user,
ticket_type: ticket_type,
status: 'active'
)
@tickets << ticket
# Send confirmation email for each ticket
TicketMailer.purchase_confirmation(ticket).deliver_now
end
end
render 'payment_success'
else
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
end
rescue Stripe::StripeError => e
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{e.message}"
rescue => e
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{e.message}"
end
end
# Download ticket PDF
def download_ticket
@ticket = current_user.tickets.find(params[:ticket_id])
respond_to do |format|
format.pdf do
pdf = @ticket.to_pdf
send_data pdf,
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
type: 'application/pdf',
disposition: 'attachment'
end
end
end end
private private
# Find and set the current event with eager-loaded associations
#
# Loads event with ticket types to avoid N+1 queries
# Raises ActiveRecord::RecordNotFound if event doesn't exist
def set_event def set_event
@event = Event.includes(:ticket_types).find(params[:id]) @event = Event.find(params[:id])
end end
end end

View File

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

View File

@@ -1,310 +0,0 @@
# Handle order management and checkout process
#
# 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_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt, :invoice ]
before_action :set_event, only: [ :new, :create ]
# 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 event_path(@event.slug, @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 event_path(@event.slug, @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)
redirect_to checkout_order_path(@order)
else
redirect_to event_order_new_path(@event.slug, @event.id)
end
rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue"
flash[:alert] = "Une erreur est survenue: #{error_message}"
redirect_to event_order_new_path(@event.slug, @event.id)
end
# Display all user orders
def index
@orders = current_user.orders.includes(:event, tickets: :ticket_type)
.where(status: [ "paid", "completed" ])
.order(created_at: :desc)
.page(params[:page])
end
# Display order summary
def show
@tickets = @order.tickets.includes(:ticket_type)
end
# Display payment page for an order
#
# Display a summary of all tickets in the order and permit user
# to proceed to payment via Stripe
def checkout
# Handle expired orders
if @order.expired?
@order.expire_if_overdue!
return redirect_to event_path(@order.event.slug, @order.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 event_path(@order.event.slug, @order.event),
alert: "Cette commande ne peut plus être payée"
return
end
# For POST requests, increment the payment attempt counter
if request.post?
@order.increment_payment_attempt!
end
redirect_to checkout_order_path(@order)
end
# Display invoice for an order
def invoice
unless @order.status == "paid" || @order.status == "completed"
redirect_to order_path(@order), alert: "La facture n'est disponible qu'après le paiement de la commande"
return
end
@tickets = @order.tickets.includes(:ticket_type)
# Get the Stripe invoice if it exists
begin
@stripe_invoice_id = @order.create_stripe_invoice!
@stripe_invoice_pdf_url = @order.stripe_invoice_pdf_url if @stripe_invoice_id
rescue => e
Rails.logger.error "Failed to retrieve or create Stripe invoice for order #{@order.id}: #{e.message}"
@stripe_invoice_id = nil
@stripe_invoice_pdf_url = nil
end
end
# Handle successful payment
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 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
# This creates accounting records without blocking the payment success flow
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}"
# Don't fail the payment process due to job scheduling issues
end
# Email confirmation is handled by the order model's mark_as_paid! method
# to avoid duplicate emails
# Clear session data
session.delete(:pending_cart)
session.delete(:ticket_names)
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 failure/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?
redirect_to checkout_order_path(order),
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
private
def set_order
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to root_path, alert: "Commande non trouvée"
end
def set_event
@event = Event.includes(:ticket_types).find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to events_path, alert: "Événement non trouvé"
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
# No service fee added to customer; deducted from promoter payout
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

View File

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

View File

@@ -1,129 +0,0 @@
# 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, :duplicate ]
# 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
# Duplicate an event and all its ticket types
def duplicate
clone_ticket_types = params[:clone_ticket_types] == "true"
@new_event = @event.duplicate(clone_ticket_types: clone_ticket_types)
if @new_event
redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié."
else
redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement."
end
end
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, :allow_booking_during_event
)
end
end

View File

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

View File

@@ -1,115 +0,0 @@
# Legacy tickets controller - redirects to new order system
#
# This controller now primarily handles legacy redirects and backward compatibility
# Most ticket creation functionality has been moved to OrdersController
class TicketsController < ApplicationController
before_action :authenticate_user!, only: [ :payment_success, :payment_cancel, :show, :download ]
before_action :set_event, only: [ :checkout, :retry_payment ]
# Redirect to order-based checkout
def checkout
# Check for draft order
if session[:draft_order_id].present?
order = current_user.orders.find_by(id: session[:draft_order_id], status: "draft")
if order.present?
redirect_to order_checkout_path(order)
return
end
end
# No order found
@event = Event.includes(:ticket_types).find(params[:id])
redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement"
end
# Redirect to order-based payment success
def payment_success
redirect_to order_payment_success_path(session_id: params[:session_id])
end
# Redirect to order-based payment cancel
def payment_cancel
redirect_to order_payment_cancel_path
end
# Redirect retry payment to order system
def retry_payment
@event = Event.includes(:ticket_types).find(params[:id])
# Look for draft order for this event
order = current_user.orders.find_by(event: @event, status: "draft")
if order&.can_retry_payment?
redirect_to retry_payment_order_path(order)
else
redirect_to event_path(@event.slug, @event),
alert: "Aucune commande disponible pour un nouveau paiement"
end
end
# Display ticket details
def show
# Find ticket by qr code id
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
.find_by(tickets: { qr_code: params[:qr_code] })
if @ticket.nil?
redirect_to dashboard_path, alert: "Billet non trouvé"
return
end
@event = @ticket.event
@order = @ticket.order
rescue ActiveRecord::RecordNotFound
redirect_to dashboard_path, alert: "Billet non trouvé"
end
# Download PDF ticket - only accessible by ticket owner
# User must be authenticated to download ticket
# TODO: change ID to an unique identifier (UUID)
def download
# Find ticket by qr code id
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
.find_by(tickets: { qr_code: params[:qr_code] })
if @ticket.nil?
redirect_to dashboard_path, alert: "Billet non trouvé ou vous n'avez pas l'autorisation d'accéder à ce billet"
return
end
# Generate PDF
pdf_content = @ticket.to_pdf
# Send PDF as download
send_data pdf_content,
filename: "ticket_#{@ticket.id}_#{@ticket.event.name.parameterize}.pdf",
type: "application/pdf",
disposition: "attachment"
rescue ActiveRecord::RecordNotFound
redirect_to dashboard_path, alert: "Billet non trouvé"
rescue => e
Rails.logger.error "Error generating ticket PDF: #{e.message}"
redirect_to dashboard_path, alert: "Erreur lors de la génération du billet"
end
private
def set_event
event_id = params[:id] || session[:event_id]
Rails.logger.debug "TicketsController#set_event - params[:id]: #{params[:id].inspect}, session[:event_id]: #{session[:event_id].inspect}"
unless event_id
Rails.logger.error "TicketsController#set_event - No event ID found"
redirect_to events_path, alert: "Aucun événement spécifié"
return
end
@event = Event.includes(:ticket_types).find(event_id)
Rails.logger.debug "TicketsController#set_event - Found event: #{@event.id} - #{@event.name}"
rescue ActiveRecord::RecordNotFound
Rails.logger.error "TicketsController#set_event - Event not found with ID: #{event_id}"
redirect_to events_path, alert: "Événement non trouvé"
end
end

View File

@@ -1,12 +1,9 @@
module ApplicationHelper module ApplicationHelper
# Convert price from cents to float # Convert prince 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
# Include flash message helpers # Include flash message helpers
include FlashMessagesHelper include FlashMessagesHelper
# Include Stripe helper
include StripeHelper
end end

View File

@@ -1,16 +1,4 @@
# 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"
@@ -26,14 +14,6 @@ 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

@@ -1,82 +0,0 @@
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

View File

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

View File

@@ -1,17 +0,0 @@
module StripeHelper
# Safely call Stripe methods with error handling
def safe_stripe_call(&block)
# Check if Stripe is properly configured
return nil unless Rails.application.config.stripe[:secret_key].present?
# Stripe is now initialized at application startup
Rails.logger.debug "Using globally initialized Stripe"
begin
yield if block_given?
rescue Stripe::StripeError => e
Rails.logger.error "Stripe Error: #{e.message}"
nil
end
end
end

View File

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

View File

@@ -1,23 +1,3 @@
// Entry point for the build script in your package.json // Entry point for the build script in your package.json
// This file initializes the Rails application with Turbo and Stimulus controllers
// Import Turbo Rails for SPA-like navigation
import "@hotwired/turbo-rails"; import "@hotwired/turbo-rails";
// 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

@@ -0,0 +1,55 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
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",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }

View File

@@ -1,20 +1,14 @@
// Main Stimulus application controller
// Initializes the Stimulus framework and makes it available globally
import { Application } from "@hotwired/stimulus"; import { Application } from "@hotwired/stimulus";
import Alpine from "alpinejs";
// Create and start the Stimulus application
const application = Application.start(); const application = Application.start();
// Configure Stimulus development experience // Configure Stimulus development experience
// Set to false in production to avoid unnecessary logging
application.debug = false; application.debug = false;
// Make Stimulus globally available for debugging purposes
window.Stimulus = application; window.Stimulus = application;
// Configure Alpine js (commented out as it's not currently used) // Configure and load Alpine
// import Alpine from "alpinejs"; window.Alpine = Alpine;
// window.Alpine = Alpine; Alpine.start();
// Alpine.start();
export { application }; export { application };

View File

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

View File

@@ -1,41 +1,31 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
// Counter controller for animating number increments
// Used for statistics and numerical displays that animate when they come into view
export default class extends Controller { export default class extends Controller {
// Define controller values with defaults
static values = { static values = {
target: { type: Number, default: 0 }, // Target number to count to target: { type: Number, default: 0 },
decimal: { type: Boolean, default: false }, // Whether to display decimal values decimal: { type: Boolean, default: false },
duration: { type: Number, default: 2000 } // Animation duration in milliseconds duration: { type: Number, default: 2000 }
} }
// Set up the intersection observer when the controller connects
connect() { connect() {
// Create an intersection observer to trigger animation when element is visible
this.observer = new IntersectionObserver((entries) => { this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
// Start animation when element is 50% visible
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.animate() this.animate()
// Stop observing after animation starts
this.observer.unobserve(this.element) this.observer.unobserve(this.element)
} }
}) })
}, { threshold: 0.5 }) }, { threshold: 0.5 })
// Begin observing this element
this.observer.observe(this.element) this.observer.observe(this.element)
} }
// Clean up the observer when the controller disconnects
disconnect() { disconnect() {
if (this.observer) { if (this.observer) {
this.observer.disconnect() this.observer.disconnect()
} }
} }
// Animate the counter from 0 to the target value
animate() { animate() {
// Find the target element with data-target-value // Find the target element with data-target-value
const targetElement = this.element.querySelector('.stat-number'); const targetElement = this.element.querySelector('.stat-number');
@@ -47,17 +37,15 @@ export default class extends Controller {
const startValue = 0; const startValue = 0;
const startTime = performance.now(); const startTime = performance.now();
// Update counter function using requestAnimationFrame for smooth animation
const updateCounter = (currentTime) => { const updateCounter = (currentTime) => {
const elapsedTime = currentTime - startTime; const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / this.durationValue, 1); const progress = Math.min(elapsedTime / this.durationValue, 1);
// Easing function for smooth animation (ease-out quartic) // Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4); const easeOutQuart = 1 - Math.pow(1 - progress, 4);
let currentValue = startValue + (this.targetValue - startValue) * easeOutQuart; let currentValue = startValue + (this.targetValue - startValue) * easeOutQuart;
// Format value based on decimal setting
if (this.decimalValue && this.targetValue < 10) { if (this.decimalValue && this.targetValue < 10) {
currentValue = currentValue.toFixed(1); currentValue = currentValue.toFixed(1);
} else { } else {
@@ -67,11 +55,9 @@ export default class extends Controller {
// Update only the text content of the target element // Update only the text content of the target element
targetElement.textContent = currentValue; targetElement.textContent = currentValue;
// Continue animation until complete
if (progress < 1) { if (progress < 1) {
requestAnimationFrame(updateCounter); requestAnimationFrame(updateCounter);
} else { } else {
// Ensure final value is exactly the target
const finalValue = this.decimalValue && this.targetValue < 10 const finalValue = this.decimalValue && this.targetValue < 10
? this.targetValue.toFixed(1) ? this.targetValue.toFixed(1)
: this.targetValue; : this.targetValue;
@@ -79,7 +65,6 @@ export default class extends Controller {
} }
} }
// Start the animation
requestAnimationFrame(updateCounter); requestAnimationFrame(updateCounter);
} }
} }

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["card"]
static classes = ["visible"]
static values = {
threshold: { type: Number, default: 0.1 },
rootMargin: { type: String, default: '0px 0px -50px 0px' },
staggerDelay: { type: Number, default: 0.2 }
}
connect() {
console.log("FeaturedEventController connected")
this.setupIntersectionObserver()
this.setupStaggeredAnimations()
}
disconnect() {
if (this.observer) {
this.observer.disconnect()
}
}
setupIntersectionObserver() {
const observerOptions = {
threshold: this.thresholdValue,
rootMargin: this.rootMarginValue
}
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
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)
})
}
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

@@ -1,46 +1,32 @@
import { Controller } from "@hotwired/stimulus"; import { Controller } from "@hotwired/stimulus"
// Controller for handling flash messages
// Automatically dismisses messages after a timeout and handles manual closing
export default class extends Controller { export default class extends Controller {
// Define targets for the controller static targets = ["message"]
static targets = ["message"];
// Initialize the controller when it connects to the DOM
connect() { connect() {
// console.log("FlashMessageController mounted", this.element); console.log("FlashMessageController mounted", this.element);
console.log("FlashMessageController mounted");
// Initialize Lucide icons for this element if available // Initialize Lucide icons for this element
if (typeof lucide !== "undefined") { if (typeof lucide !== 'undefined') {
lucide.createIcons({ within: this.element }); lucide.createIcons({ within: this.element });
} }
// Auto-dismiss after 2 seconds // Auto-dismiss after 2 seconds
this.timeout = setTimeout(() => { this.timeout = setTimeout(() => {
this.close(); this.close()
}, 5000); }, 2000)
} }
// Clean up the timeout when the controller disconnects
disconnect() { disconnect() {
if (this.timeout) { if (this.timeout) {
clearTimeout(this.timeout); clearTimeout(this.timeout)
} }
} }
// Close the flash message with a fade-out animation
close() { close() {
// Add opacity transition classes this.element.classList.add('opacity-0', 'transition-opacity', 'duration-300')
this.element.classList.add(
"opacity-0",
"transition-opacity",
"duration-300",
);
// Remove element after transition completes
setTimeout(() => { setTimeout(() => {
this.element.remove(); this.element.remove()
}, 300); }, 300)
} }
} }

View File

@@ -1,73 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Controller for handling the header navigation
// Manages mobile menu toggle and user dropdown menu
export default class extends Controller {
static targets = ["mobileMenu", "mobileMenuButton", "userMenu", "userMenuButton"]
connect() {
// Initialize menu states
this.mobileMenuOpen = false
this.userMenuOpen = false
// Add click outside listener for user menu
this.clickOutsideHandler = this.handleClickOutside.bind(this)
document.addEventListener("click", this.clickOutsideHandler)
}
disconnect() {
// Clean up event listener
document.removeEventListener("click", this.clickOutsideHandler)
}
// Toggle mobile menu visibility
toggleMobileMenu() {
this.mobileMenuOpen = !this.mobileMenuOpen
this.mobileMenuTarget.classList.toggle("hidden", !this.mobileMenuOpen)
// Update button icon based on state
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
if (iconOpen && iconClose) {
iconOpen.classList.toggle("hidden", this.mobileMenuOpen)
iconClose.classList.toggle("hidden", !this.mobileMenuOpen)
}
}
// Toggle user dropdown menu visibility
toggleUserMenu() {
this.userMenuOpen = !this.userMenuOpen
if (this.hasUserMenuTarget) {
this.userMenuTarget.classList.toggle("hidden", !this.userMenuOpen)
}
}
// Close menus when clicking outside
handleClickOutside(event) {
// Close user menu if clicked outside
if (this.userMenuOpen && this.hasUserMenuTarget &&
!this.userMenuTarget.contains(event.target) &&
!this.userMenuButtonTarget.contains(event.target)) {
this.userMenuOpen = false
this.userMenuTarget.classList.add("hidden")
}
// Close mobile menu if clicked outside
if (this.mobileMenuOpen &&
!this.mobileMenuTarget.contains(event.target) &&
!this.mobileMenuButtonTarget.contains(event.target)) {
this.mobileMenuOpen = false
this.mobileMenuTarget.classList.add("hidden")
// Update button icon
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
if (iconOpen && iconClose) {
iconOpen.classList.remove("hidden")
iconClose.classList.add("hidden")
}
}
}
}

View File

@@ -2,31 +2,18 @@
// 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); import FlashMessageController from "./flash_message_controller"
import CounterController from "./counter_controller"
import FeaturedEventController from "./featured_event_controller"
import CounterController from "./counter_controller"; import ShadcnTestController from "./shadcn_test_controller"
application.register("counter", CounterController);
import FlashMessageController from "./flash_message_controller"; application.register("logout", LogoutController) // Allow logout using js
application.register("flash-message", FlashMessageController); application.register("flash-message", FlashMessageController) // Dismiss notification after 5 secondes
application.register("counter", CounterController) // Simple counter for homepage
application.register("featured-event", FeaturedEventController) // Featured event controller for homepage
import TicketSelectionController from "./ticket_selection_controller"; application.register("shadcn-test", ShadcnTestController) // Test controller for Shadcn
application.register("ticket-selection", TicketSelectionController);
import HeaderController from "./header_controller";
application.register("header", HeaderController);
import QrCodeController from "./qr_code_controller";
application.register("qr-code", QrCodeController);
import EventFormController from "./event_form_controller";
application.register("event-form", EventFormController);
import CountdownController from "./countdown_controller";
application.register("countdown", CountdownController);
import EventDuplicationController from "./event_duplication_controller";
application.register("event-duplication", EventDuplicationController);

View File

@@ -1,20 +1,15 @@
import { Controller } from "@hotwired/stimulus"; import { Controller } from "@hotwired/stimulus";
// Controller for handling user logout functionality
// Sends a DELETE request to the server to sign out the user
export default class extends Controller { export default class extends Controller {
// Define controller values
static values = { static values = {
url: String, // Optional URL for logout endpoint url: String,
}; };
// Log when the controller is mounted
connect() { connect() {
// Display a message when the controller is mounted // Display a message when the controller is mounted
// console.log("LogoutController mounted", this.element); console.log("LogoutController mounted", this.element);
} }
// Handle the sign out action
signOut(event) { signOut(event) {
event.preventDefault(); event.preventDefault();
console.log("User clicked on logout button."); console.log("User clicked on logout button.");
@@ -22,7 +17,7 @@ export default class extends Controller {
// Ensure user wants to disconnect with a confirmation request // Ensure user wants to disconnect with a confirmation request
// if (this.hasUrlValue && !confirm(this.element.dataset.confirm)) { return; } // if (this.hasUrlValue && !confirm(this.element.dataset.confirm)) { return; }
// Retrieve the csrf token from header for security // Retrieve the csrf token from header
const csrfToken = document.querySelector("[name='csrf-token']").content; const csrfToken = document.querySelector("[name='csrf-token']").content;
// Define url to redirect user when action is valid // Define url to redirect user when action is valid

View File

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

View File

@@ -0,0 +1,39 @@
import { Controller } from "@hotwired/stimulus"
import React from "react"
import { createRoot } from "react-dom/client"
import { Button } from "@/components/button"
// Connects to data-controller="shadcn-test"
export default class extends Controller {
static targets = ["container"]
connect() {
console.log("Shadcn Button Test Controller connected")
this.renderButton()
}
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>
)
}
handleClick = () => {
alert("✅ Le bouton shadcn fonctionne avec PostCSS !")
console.log("Shadcn button clicked - PostCSS compilation successful")
}
}

View File

@@ -0,0 +1,177 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["quantity", "cartCount", "cartTotal", "checkoutButton"]
static values = { eventId: String }
connect() {
this.cart = {}
this.updateCartDisplay()
// Check for pending cart in session storage (after login)
this.checkForPendingCart()
}
increaseQuantity(event) {
const ticketTypeId = event.params.ticketTypeId
const max = parseInt(event.params.max)
const input = this.quantityTargetFor(ticketTypeId)
const current = parseInt(input.value) || 0
if (current < max) {
input.value = current + 1
this.updateCartItem(ticketTypeId, input)
}
}
decreaseQuantity(event) {
const ticketTypeId = event.params.ticketTypeId
const input = this.quantityTargetFor(ticketTypeId)
const current = parseInt(input.value) || 0
if (current > 0) {
input.value = current - 1
this.updateCartItem(ticketTypeId, input)
}
}
updateQuantityFromInput(event) {
const input = event.target
const ticketTypeId = input.dataset.ticketTypeId
const max = parseInt(input.max)
const quantity = parseInt(input.value) || 0
// Validate input
if (quantity < 0) {
input.value = 0
} else if (quantity > max) {
input.value = max
}
this.updateCartItem(ticketTypeId, input)
}
updateCartItem(ticketTypeId, input) {
const name = input.dataset.name
const price = parseInt(input.dataset.price)
const quantity = parseInt(input.value) || 0
if (quantity > 0) {
this.cart[ticketTypeId] = {
name: name,
price: price,
quantity: quantity
}
} else {
delete this.cart[ticketTypeId]
}
this.updateCartDisplay()
}
updateCartDisplay() {
let totalTickets = 0
let totalPrice = 0
for (let ticketTypeId in this.cart) {
totalTickets += this.cart[ticketTypeId].quantity
totalPrice += (this.cart[ticketTypeId].price * this.cart[ticketTypeId].quantity) / 100
}
// Update cart count and total
if (this.hasCartCountTarget) {
this.cartCountTarget.textContent = totalTickets
}
if (this.hasCartTotalTarget) {
this.cartTotalTarget.textContent = totalPrice.toFixed(2)
}
// Update checkout button state
if (this.hasCheckoutButtonTarget) {
const checkoutBtn = this.checkoutButtonTarget
if (totalTickets > 0) {
checkoutBtn.disabled = false
checkoutBtn.classList.remove('opacity-50', 'cursor-not-allowed')
} else {
checkoutBtn.disabled = true
checkoutBtn.classList.add('opacity-50', 'cursor-not-allowed')
}
}
}
proceedToCheckout() {
if (Object.keys(this.cart).length === 0) {
alert('Veuillez sélectionner au moins un billet')
return
}
// Check if user is authenticated
const isAuthenticated = document.body.dataset.userAuthenticated === "true"
if (!isAuthenticated) {
if (confirm('Vous devez être connecté pour acheter des billets. Souhaitez-vous vous connecter maintenant ?')) {
// Store cart in session storage
sessionStorage.setItem('pending_cart', JSON.stringify({
eventId: this.eventIdValue,
cart: this.cart
}))
window.location.href = '/auth/sign_in'
}
return
}
// Create form and submit to checkout
const form = document.createElement('form')
form.method = 'POST'
form.action = `/events/${document.body.dataset.eventSlug}.${this.eventIdValue}/checkout`
// Add CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
const csrfInput = document.createElement('input')
csrfInput.type = 'hidden'
csrfInput.name = 'authenticity_token'
csrfInput.value = csrfToken
form.appendChild(csrfInput)
// Add cart data
const cartInput = document.createElement('input')
cartInput.type = 'hidden'
cartInput.name = 'cart'
cartInput.value = JSON.stringify(this.cart)
form.appendChild(cartInput)
document.body.appendChild(form)
form.submit()
}
checkForPendingCart() {
const pendingCart = sessionStorage.getItem('pending_cart')
if (pendingCart) {
try {
const cartData = JSON.parse(pendingCart)
if (cartData.eventId == this.eventIdValue) {
this.cart = cartData.cart
this.updateCartDisplay()
// Restore quantities in inputs
for (let ticketTypeId in this.cart) {
const input = this.quantityTargetFor(ticketTypeId)
if (input) {
input.value = this.cart[ticketTypeId].quantity
}
}
}
sessionStorage.removeItem('pending_cart')
} catch (e) {
console.error('Error restoring pending cart:', e)
sessionStorage.removeItem('pending_cart')
}
}
}
// Helper method to find quantity input by ticket type ID
quantityTargetFor(ticketTypeId) {
return document.querySelector(`#quantity_${ticketTypeId}`)
}
}

View File

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

6
app/javascript/lib/utils.js Executable file
View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

View File

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

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -1,160 +0,0 @@
class Order < ApplicationRecord
# === Constants ===
DRAFT_EXPIRY_TIME = 15.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
# Send purchase confirmation email outside the transaction
# so that payment completion isn't affected by email failures
begin
TicketMailer.purchase_confirmation_order(self).deliver_now
rescue StandardError => e
Rails.logger.error "Failed to send purchase confirmation email for order #{id}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
# Don't re-raise the error - payment should still succeed
end
end
# Calculate total from ticket prices only (platform fee deducted from promoter payout)
def calculate_total!
ticket_total = tickets.sum(:price_cents)
update!(total_amount_cents: ticket_total)
end
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
def platform_fee_cents
tickets.sum do |ticket|
fixed_fee = 50 # €0.50 in cents
percentage_fee = (ticket.price_cents * 0.015).to_i
fixed_fee + percentage_fee
end
end
# Promoter payout amount after platform fee deduction
def promoter_payout_cents
total_amount_cents - platform_fee_cents
end
def platform_fee_euros
platform_fee_cents / 100.0
end
def promoter_payout_euros
promoter_payout_cents / 100.0
end
# 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,23 +1,15 @@
class Ticket < ApplicationRecord class Ticket < ApplicationRecord
# === Associations === # Associations
belongs_to :order belongs_to :user
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 :order_id, presence: true validates :user_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[active used expired refunded] }
validates :first_name, presence: true
validates :last_name, presence: true
# === Scopes ===
scope :draft, -> { where(status: "draft") }
scope :active, -> { where(status: "active") }
scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) }
before_validation :set_price_from_ticket_type, on: :create before_validation :set_price_from_ticket_type, on: :create
before_validation :generate_qr_code, on: :create before_validation :generate_qr_code, on: :create
@@ -32,24 +24,6 @@ class Ticket < ApplicationRecord
price_cents / 100.0 price_cents / 100.0
end end
# Delegate payment methods to order
def can_retry_payment?
order.can_retry_payment?
end
def expired?
order.expired?
end
def expiring_soon?
order.expiring_soon?
end
# Mark ticket as expired if it's past expiry time
def expire_if_overdue!
order.expire_if_overdue!
end
private private
def set_price_from_ticket_type def set_price_from_ticket_type
@@ -64,13 +38,5 @@ 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
def draft?
status == "draft"
end end
end end

View File

@@ -1,7 +1,7 @@
class TicketType < ApplicationRecord class TicketType < ApplicationRecord
# Associations # Associations
belongs_to :event belongs_to :event
has_many :tickets, dependent: :destroy # Cannot delete ticket types if already tickets sold has_many :tickets, dependent: :destroy
# Validations # Validations
validates :name, presence: true, length: { minimum: 3, maximum: 50 } validates :name, presence: true, length: { minimum: 3, maximum: 50 }
@@ -10,66 +10,11 @@ class TicketType < ApplicationRecord
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :sale_start_at, presence: true validates :sale_start_at, presence: true
validates :sale_end_at, presence: true validates :sale_end_at, presence: true
validate :sale_end_after_start
validates :requires_id, inclusion: { in: [ true, false ] }
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
@@ -77,13 +22,4 @@ 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
# Only enforce this restriction if booking during event is not allowed
unless event.allow_booking_during_event?
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
end
end
end end

View File

@@ -22,30 +22,9 @@ 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 - allow reasonable name lengths # Validations
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true } validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true }
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true } validates :first_name, length: { minimum: 3, maximum: 12, allow_blank: true }
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true } validates :company_name, length: { minimum: 3, maximum: 12, allow_blank: true }
# Onboarding methods
def needs_onboarding?
!onboarding_completed?
end
def complete_onboarding!
update!(onboarding_completed: true)
end
# Authorization methods
def can_manage_events?
# Only professional users can manage events
is_professionnal?
end
def promoter?
# Alias for can_manage_events? to make views more semantic
can_manage_events?
end
end end

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@
<div class="container"> <div class="container">
<div class="event-finder"> <div class="event-finder">
<div class="finder-header"> <div class="finder-header">
<h2 class="finder-title">Trouvez votre événement parfait</h2> <h2 class="finder-title">Find Your Perfect Event</h2>
<p class="finder-subtitle">Découvrez des événements afterwork adaptés à vos préférences</p> <p class="finder-subtitle">Discover afterwork events tailored to your preferences</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>
Ville City
</label> </label>
<select class="finder-select focus-ring" id="event-city"> <select class="finder-select focus-ring" id="event-city">
<option value="">Choisissez une ville</option> <option value="">Choose a city</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>
Type d'événement Event Type
</label> </label>
<select class="finder-select focus-ring" id="event-type"> <select class="finder-select focus-ring" id="event-type">
<option value="">Tous les types</option> <option value="">All types</option>
<option value="networking">Réseautage</option> <option value="networking">Networking</option>
<option value="tech">Tech & Innovation</option> <option value="tech">Tech & Innovation</option>
<option value="creative">Créatif & Design</option> <option value="creative">Creative & Design</option>
<option value="business">Affaires</option> <option value="business">Business</option>
<option value="startup">Startup</option> <option value="startup">Startup</option>
<option value="wine">Vin & Dégustation</option> <option value="wine">Wine & Tasting</option>
<option value="art">Art & Culture</option> <option value="art">Art & Culture</option>
<option value="music">Musique & Divertissement</option> <option value="music">Music & Entertainment</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>
Fourchette de prix Price Range
</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;">à</span> <span style="color: var(--color-neutral-500); font-weight: 600;">to</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>
Trouver des événements Find Events
</button> </button>
</form> </form>
</div> </div>
@@ -81,7 +81,7 @@
</section> </section>
<script> <script>
// Fonctionnalité de recherche d'événements // Event Finder Functionality
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('Filtres de recherche :', formData); console.log('Search filters:', 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> Recherche...'; 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...';
// Simulate search // Simulate search
setTimeout(() => { setTimeout(() => {
searchBtn.innerHTML = originalText; searchBtn.innerHTML = originalText;
alert('Recherche terminée ! Les résultats seraient affichés ici.'); alert('Search completed! Results would be displayed here.');
}, 2000); }, 2000);
} }
}); });

View File

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

View File

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

View File

@@ -1,145 +0,0 @@
<!--
Note: The logout method is handled by logout_controller
in app/javascript/controllers/logout_controller.js
-->
<nav x-data="{ open: false }" class="bg-black border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<%= link_to "Cyanet", "test", class: "text-white" %>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<!-- Dashboard -->
<%= link_to "Espace client", "#",
class: "inline-flex
items-center px-1 pt-1 border-b-2 border-transparent
text-sm font-medium leading-5 text-gray-300
hover:text-gray-500 hover:border-gray-300 focus:outline-none
focus:text-gray-700 focus:border-gray-300 transition
duration-150 ease-in-out" %>
<!-- ./Dashboard -->
<!-- My services -->
<%= link_to "Mes services", "#",
class: "inline-flex
items-center px-1 pt-1 border-b-2 border-transparent
text-sm font-medium leading-5 text-gray-300
hover:text-gray-500 hover:border-gray-300 focus:outline-none
focus:text-gray-700 focus:border-gray-300 transition
duration-150 ease-in-out" %>
<!-- ./My services-->
<!-- My services -->
<%= link_to "Mes factures", "#",
class: "inline-flex
items-center px-1 pt-1 border-b-2 border-transparent
text-sm font-medium leading-5 text-gray-300
hover:text-gray-500 hover:border-gray-300 focus:outline-none
focus:text-gray-700 focus:border-gray-300 transition
duration-150 ease-in-out" %>
<!-- ./My services-->
<!-- Support -->
<%= link_to "Support", "#",
class: "inline-flex
items-center px-1 pt-1 border-b-2 border-transparent
text-sm font-medium leading-5 text-gray-300
hover:text-gray-500 hover:border-gray-300 focus:outline-none
focus:text-gray-700 focus:border-gray-300 transition
duration-150 ease-in-out" %>
<!-- ./Support-->
</div>
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6">
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
<div @click="open = ! open">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>Mon profil</div>
<div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</button>
</div>
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 w-48 rounded-md shadow-lg origin-top-right right-0"
style="display: none;"
@click="open = false">
<div class="rounded-md ring-1 ring-black ring-opacity-5 py-1 bg-white">
<%= link_to "Profil", "#", class: "block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out" %>
<!-- Logout -->
<%= link_to "Déconnexion", 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: "inline-block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out" %>
</div>
</div>
</div>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{ 'hidden': open, 'inline-flex': !open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{ 'hidden': !open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu -->
<div :class="{ 'block': open, 'hidden': !open }" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<%= link_to "Dashboard", "test", class: "block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-400 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" %>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">Test</div>
<div class="font-medium text-sm text-gray-500">Test</div>
</div>
<div class="mt-3 space-y-1">
<%= link_to "Profile", "test", class: "block w-full pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-400 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" %>
<!-- Logout -->
<%= link_to "Déconnexion", 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 w-full pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-400 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" %>
</div>
</div>
</div>
</nav>

View File

@@ -12,50 +12,57 @@
</div> </div>
</div> </div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center mt-4 gap-3"> <div class="flex justify-between items-center mt-4">
<div class="<%= 'order-2 sm:order-1' unless sold_out %>"> <div>
<% if sold_out %> <% if sold_out %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<i data-lucide="x" class="w-2 h-2 mr-1 text-red-400"></i> <svg class="-ml-0.5 mr-1 h-2 w-2 text-red-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
Épuisé Épuisé
</span> </span>
<% else %> <% else %>
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-50 text-gray-600 border border-gray-200"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<i data-lucide="ticket" class="w-3 h-3 mr-1 text-green-500"></i> <svg class="-ml-0.5 mr-1 h-2 w-2 text-green-400" fill="currentColor" viewBox="0 0 8 8">
<%= remaining %> <circle cx="4" cy="4" r="3" />
</svg>
<%= remaining %> disponibles
</span> </span>
<% end %> <% end %>
</div> </div>
<% unless sold_out %> <% unless sold_out %>
<div class="flex items-center space-x-2 order-1 sm:order-2"> <div class="flex items-center space-x-2">
<button type="button" <button type="button"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200" class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
data-action="ticket-selection#decrement" data-action="click->ticket-cart#decreaseQuantity"
data-target="<%= id %>"> data-ticket-cart-ticket-type-id-param="<%= id %>"
data-ticket-cart-max-param="<%= remaining %>">
<span class="text-gray-600 font-bold">-</span> <span class="text-gray-600 font-bold">-</span>
</button> </button>
<input type="number" <input type="number"
id="ticket_quantity_<%= id %>" id="quantity_<%= id %>"
name="cart[<%= id %>][quantity]"
min="0" min="0"
max="<%= remaining %>" max="<%= remaining %>"
value="0" value="0"
class="w-12 text-center border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1 ticket-quantity-input" class="w-12 text-center border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1"
data-ticket-selection-target="quantityInput" data-ticket-cart-target="quantity"
data-action="change->ticket-selection#updateQuantity" data-ticket-type-id="<%= id %>"
data-price="<%= price_cents %>" data-name="<%= name %>"
data-target="<%= id %>"> data-price="<%= price_cents %>">
<button type="button" <button type="button"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200" class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
data-action="ticket-selection#increment" data-action="click->ticket-cart#increaseQuantity"
data-target="<%= id %>"> data-ticket-cart-ticket-type-id-param="<%= id %>"
data-ticket-cart-max-param="<%= remaining %>">
<span class="text-gray-600 font-bold">+</span> <span class="text-gray-600 font-bold">+</span>
</button> </button>
</div> </div>
<% else %> <% else %>
<div class="text-sm text-gray-500 font-medium order-1 sm:order-2"> <div class="text-sm text-gray-500 font-medium">
<i data-lucide="lock" class="w-5 h-5 inline-block mr-1"></i> <svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Indisponible Indisponible
</div> </div>
<% end %> <% end %>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,82 +1,51 @@
<div class="max-w-6xl 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">
<!-- Breadcrumb --> <div class="flex justify-between items-center mb-8">
<%= render 'components/breadcrumb', crumbs: [ <h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1>
{ name: 'Accueil', path: root_path }, <div class="text-sm text-gray-500">
{ name: 'Événements', path: events_path }
] %>
<!-- Page Header -->
<header class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900">Événements à venir</h1>
<p class="text-gray-600 mt-2">Découvrez les meilleurs afterworks et événements de Paris</p>
</div>
<div class="bg-purple-100 text-purple-800 px-4 py-2 rounded-full text-sm font-medium">
<%= @events.total_count %> événements trouvés <%= @events.total_count %> événements trouvés
</div> </div>
</header> </div>
<!-- Events Grid -->
<% if @events.any? %> <% if @events.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<% @events.each do |event| %> <% @events.each do |event| %>
<article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1"> <div class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<%= link_to event_path(event.slug, event), class: "block" do %>
<% if event.image.present? %> <% if event.image.present? %>
<div class="relative overflow-hidden aspect-[4/3]"> <div class="h-48 overflow-hidden">
<img <%= image_tag event.image, class: "w-full h-full object-cover" %>
src="<%= event.image %>"
alt="<%= event.name %>"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
>
<!-- Event featured badge -->
<% if event.featured? %>
<div class="absolute top-4 left-4">
<span class="bg-yellow-400 text-gray-900 px-3 py-1 rounded-full text-sm font-medium shadow-lg">
★ En vedette
</span>
</div>
<% end %>
<!-- Date badge -->
<div class="absolute bottom-4 right-4">
<span class="bg-white/90 backdrop-blur-sm text-gray-900 px-3 py-1 rounded-full text-sm font-bold shadow-lg">
<%= event.start_time.strftime("%d/%m") %>
</span>
</div>
</div> </div>
<% else %> <% else %>
<div class="relative overflow-hidden aspect-[4/3] bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center"> <div class="h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center justify-center">
<i data-lucide="calendar" class="w-16 h-16 text-white"></i> <svg class="w-16 h-16 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- Date badge --> <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" />
<div class="absolute bottom-4 right-4"> </svg>
<span class="bg-white/90 backdrop-blur-sm text-gray-900 px-3 py-1 rounded-full text-sm font-bold shadow-lg">
<%= event.start_time.strftime("%d/%m") %>
</span>
</div>
</div> </div>
<% end %> <% end %>
<div class="p-6"> <div class="p-6">
<div class="mb-4"> <div class="flex justify-between items-start mb-3">
<h2 class="text-xl font-bold text-gray-900 mb-2 group-hover:text-purple-600 transition-colors line-clamp-2"><%= event.name %></h2> <div>
<p class="text-sm text-gray-500 flex items-center"> <h2 class="text-xl font-bold text-gray-900 line-clamp-1"><%= event.name %></h2>
<i data-lucide="map-pin" class="w-4 h-4 mr-2"></i> <p class="text-xs text-gray-500 flex items-center mt-1">
<%= event.venue_name.truncate(25) %> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</p> <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" />
<p class="text-sm text-gray-500 flex items-center mt-1"> </svg>
<i data-lucide="clock" class="w-4 h-4 mr-2"></i> <%= event.venue_name.truncate(20) %>
<%= l(event.start_time, format: '%A %d %B • %H:%M') %>
</p> </p>
</div> </div>
<span class="inline-flex items-center my-2 px-2.5 py-2 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<%= event.start_time.strftime("%d/%m") %>
</span>
</div>
<p class="text-gray-600 text-sm mb-4 line-clamp-2"> <div class="mb-4">
<%= event.description.truncate(100) %> <p class="text-gray-600 text-sm line-clamp-2"><%= event.description.truncate(100) %></p>
</p> </div>
<div class="flex justify-between items-center pt-4 border-t border-gray-100"> <div class="flex justify-between items-center">
<div> <div>
<% if event.ticket_types.any? %> <% if event.ticket_types.any? %>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-medium text-gray-900">
À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€ À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€
</p> </p>
<% else %> <% else %>
@@ -84,30 +53,33 @@
<% end %> <% end %>
</div> </div>
<div class="inline-flex items-center text-purple-600 font-medium text-sm group-hover:text-purple-700"> <%= 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 %>
Voir détails Détails
<i data-lucide="arrow-right" class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"></i> <svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</div> </svg>
</div>
<% end %> <% end %>
</article> </div>
</div>
</div>
<% end %> <% end %>
</div> </div>
<!-- Pagination --> <div class="mt-8 flex justify-center">
<div class="flex justify-center mt-12">
<%= paginate @events, theme: "tailwind" %> <%= paginate @events, theme: "tailwind" %>
</div> </div>
<% else %> <% else %>
<!-- Empty State -->
<div class="text-center py-16"> <div class="text-center py-16">
<div class="w-24 h-24 bg-gradient-to-br from-purple-100 to-blue-100 rounded-full flex items-center justify-center mx-auto mb-6"> <div class="mx-auto max-w-md">
<i data-lucide="calendar-x" class="w-12 h-12 text-purple-600"></i> <div class="w-24 h-24 mx-auto bg-gradient-to-r from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mb-6">
<svg class="w-12 h-12 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun événement disponible</h3>
<p class="text-gray-500 mb-6">Il n'y a aucun événement à venir pour le moment.</p>
<%= link_to "Retour à l'accueil", root_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %>
</div> </div>
<h3 class="text-2xl font-bold text-gray-900 mb-4">Aucun événement disponible</h3>
<p class="text-gray-600 mb-8 max-w-md mx-auto">Il n'y a aucun événement à venir pour le moment. Revenez bientôt pour découvrir de nouvelles sorties!</p>
<%= link_to "<i data-lucide=\"home\" class=\"w-4 h-4 mr-2\"></i> Retour à l'accueil".html_safe, root_path, class: "inline-flex items-center bg-purple-600 text-white px-6 py-3 rounded-full font-semibold hover:bg-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" %>
</div> </div>
<% end %> <% end %>
</div> </div>

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