Compare commits
144 Commits
a3689948ae
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f4f50e5b | ||
|
|
e4509b1c43 | ||
|
|
59e1854803 | ||
|
|
58141dca94 | ||
|
|
d2c43cfc2f | ||
|
|
bc09feafc1 | ||
|
|
d922d7304d | ||
|
|
0399761fb3 | ||
|
|
e5ed1a34dd | ||
| 3e0a354a58 | |||
|
|
b5c1846f2c | ||
|
|
04393add14 | ||
|
|
5279ebe1a4 | ||
|
|
329ba89eaa | ||
|
|
9c56b2e1e5 | ||
| a8d3bc12ae | |||
|
|
b228d5a174 | ||
|
|
61ad8c64d4 | ||
|
|
4e06f91acb | ||
|
|
28eddb22ab | ||
|
|
a34eb7aa38 | ||
|
|
aa68885b84 | ||
|
|
c1dde7914c | ||
|
|
dbb972e490 | ||
|
|
049e5505ef | ||
|
|
d6184b6c84 | ||
|
|
4cde466f9a | ||
|
|
ee43996a77 | ||
|
|
f0d32bf3f1 | ||
|
|
20f926cd7a | ||
|
|
d1ef962f74 | ||
|
|
e84d9aad5b | ||
|
|
24126eb834 | ||
|
|
9a1976b6af | ||
|
|
a8c7e82507 | ||
|
|
889afd0d01 | ||
|
|
82f0fab1f5 | ||
|
|
91e6425c1e | ||
|
|
f54742b041 | ||
|
|
21919c813e | ||
|
|
8ecfc7bf99 | ||
|
|
28ef801c9a | ||
|
|
55b39e93bf | ||
|
|
9bebdef5a5 | ||
|
|
d5c0276fcc | ||
|
|
10c93fff2f | ||
|
|
332827c6da | ||
|
|
46d042b85e | ||
|
|
48ec78197b | ||
|
|
31009560c2 | ||
|
|
16c277d0a9 | ||
|
|
6951efdc85 | ||
|
|
d1fb766fef | ||
|
|
a69faf0582 | ||
|
|
9b0228e7ee | ||
|
|
c5c64a87b8 | ||
|
|
4b671a211b | ||
|
|
e4d778355e | ||
|
|
eefa6c3ce2 | ||
|
|
fb447f175f | ||
|
|
ab436d8c5c | ||
|
|
6b47114015 | ||
|
|
39636039f5 | ||
|
|
5fa31f4311 | ||
|
|
070e8d0f2a | ||
|
|
89bda03f45 | ||
|
|
935974b70a | ||
|
|
f0de3dac8a | ||
|
|
9336d974ba | ||
|
|
0ede98efa4 | ||
|
|
1a7fb818df | ||
|
|
9b5d8fcf97 | ||
|
|
748f839346 | ||
|
|
83e76f71bf | ||
|
|
20ae3de7a3 | ||
|
|
6d2a6ed027 | ||
|
|
60b7bc6aa7 | ||
|
|
8d2127fce2 | ||
|
|
2fb0e1fdbb | ||
|
|
cc03bfad49 | ||
|
|
3250a6f25d | ||
|
|
ca35abe01d | ||
|
|
f2448383d4 | ||
|
|
9be7a01d93 | ||
|
|
569303b631 | ||
|
|
259837622a | ||
|
|
cf34c9c7a6 | ||
|
|
1261efc4c8 | ||
|
|
a101885d87 | ||
|
|
0b6eec0c7b | ||
|
|
8f9795d773 | ||
|
|
d1308bc988 | ||
|
|
758d461c1a | ||
|
|
67d3bcde5b | ||
|
|
bc214867b0 | ||
|
|
4bc40967c8 | ||
|
|
039ae7d1f8 | ||
|
|
f285d689b4 | ||
|
|
5581718ece | ||
|
|
b74fd49816 | ||
|
|
8ad2194d48 | ||
|
|
94d1145668 | ||
|
|
dc228b18ba | ||
|
|
38fc0059ea | ||
|
|
11340e5e58 | ||
|
|
ceb5a13297 | ||
|
|
7694e50fa0 | ||
|
|
e86b84ba61 | ||
|
|
f1750cb887 | ||
|
|
2aae7fe8ea | ||
|
|
b8efa1e26d | ||
|
|
9e6c48dc5c | ||
|
|
6e3413a128 | ||
|
|
0a3a913f66 | ||
|
|
dcaa83e756 | ||
|
|
213a11e731 | ||
|
|
ce0752bbda | ||
|
|
e983b68834 | ||
|
|
d5326c7dc6 | ||
|
|
fdad3bfb7b | ||
|
|
c3f5d72a91 | ||
|
|
241256e373 | ||
|
|
7f36abbcec | ||
|
|
73eefdd7bd | ||
|
|
29f1d75969 | ||
|
|
340f655102 | ||
|
|
974edce238 | ||
|
|
7009245ab0 | ||
|
|
a984243fe2 | ||
|
|
01b545c83e | ||
|
|
cb0de11de1 | ||
|
|
1daeee0eb1 | ||
|
|
ff32b6f21c | ||
|
|
8544802b7f | ||
|
|
0abf8d9aa9 | ||
|
|
da420ccd76 | ||
|
|
24a4560634 | ||
|
|
ed5ff4b8fd | ||
|
|
ffd9d31c94 | ||
|
|
eee7855d36 | ||
|
|
ea7517457a | ||
|
|
6d3ee7e400 | ||
|
|
15e3c7dff5 | ||
|
|
46c8faf10c |
24
.env.example
24
.env.example
@@ -1,18 +1,18 @@
|
|||||||
# Application data
|
# Application data
|
||||||
RAILS_ENV=development
|
RAILS_ENV=production
|
||||||
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=localhost
|
# DB_HOST=127.0.0.1
|
||||||
|
# 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,16 +28,18 @@ 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_PUBLISHABLE_KEY=pk_test_51S1M7BJWx6G2LLIXYpTvi0hxMpZ4tZSxkmr2Wbp1dQ73MKNp4Tyu4xFJBqLXK5nn4E0nEf2tdgJqEwWZLosO3QGn00kMvjXWGW
|
||||||
STRIPE_SECRET_KEY=sk_test_51S1M7BJWx6G2LLIXK2pdLpRKb9Mgd3sZ30N4ueVjHepgxQKbWgMVJoa4v4ESzHQ6u6zJjO4jUvgLYPU1QLyAiFTN00sGz2ortW
|
STRIPE_SECRET_KEY=sk_test_51S1M7BJWx6G2LLIXK2pdLpRKb9Mgd3sZ30N4ueVjHepgxQKbWgMVJoa4v4ESzHQ6u6zJjO4jUvgLYPU1QLyAiFTN00sGz2ortW
|
||||||
|
|||||||
93
.gitea/workflows/ai-code-review.yaml.old
Normal file
93
.gitea/workflows/ai-code-review.yaml.old
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
name: AI Code Review
|
||||||
|
run-name: AI Code Review by @${{ github.actor }} 🤖
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ai-review:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get PR diff
|
||||||
|
id: diff
|
||||||
|
run: |
|
||||||
|
# Get the diff for the PR
|
||||||
|
git fetch origin ${{ github.base_ref }}
|
||||||
|
DIFF=$(git diff origin/${{ github.base_ref }}...HEAD)
|
||||||
|
echo "diff<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$DIFF" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: AI Code Review
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
# Or use ANTHROPIC_API_KEY for Claude
|
||||||
|
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
run: |
|
||||||
|
# Install dependencies
|
||||||
|
pip install openai requests
|
||||||
|
|
||||||
|
# Create review script
|
||||||
|
cat > review.py << 'EOF'
|
||||||
|
import os
|
||||||
|
import openai
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Configure OpenAI client (or use Anthropic client for Claude)
|
||||||
|
client = openai.OpenAI(api_key=os.environ['OPENAI_API_KEY'])
|
||||||
|
|
||||||
|
# Get diff from environment
|
||||||
|
diff = """${{ steps.diff.outputs.diff }}"""
|
||||||
|
|
||||||
|
if not diff.strip():
|
||||||
|
print("No changes to review")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
# Create review prompt
|
||||||
|
prompt = f"""
|
||||||
|
Please review this code diff and provide constructive feedback:
|
||||||
|
|
||||||
|
{diff}
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Code quality and best practices
|
||||||
|
- Potential bugs or security issues
|
||||||
|
- Performance considerations
|
||||||
|
- Maintainability and readability
|
||||||
|
- Ruby on Rails specific patterns
|
||||||
|
|
||||||
|
Provide your review as structured feedback with specific line references where possible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="gpt-4", # or "claude-3-sonnet" for Claude
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
max_tokens=2000
|
||||||
|
)
|
||||||
|
|
||||||
|
review = response.choices[0].message.content
|
||||||
|
print("AI Code Review:")
|
||||||
|
print("=" * 50)
|
||||||
|
print(review)
|
||||||
|
|
||||||
|
# Post review as PR comment (requires additional API setup)
|
||||||
|
# This would need Gitea API integration
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during review: {e}")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
python review.py
|
||||||
|
|
||||||
|
- name: Comment on PR
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "Review completed - implement Gitea API integration to post comments"
|
||||||
98
.gitea/workflows/ci-mariadb.yaml
Normal file
98
.gitea/workflows/ci-mariadb.yaml
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
name: Ruby on Rails Test
|
||||||
|
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
|
||||||
|
#on: [push]
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rails-test:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb:11.7.2-noble
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||||
|
MYSQL_DATABASE: "${DB_DATABASE:-aperonight_test}"
|
||||||
|
MYSQL_USER: "${DB_USERNAME:-aperonight}"
|
||||||
|
MYSQL_PASSWORD: "${DB_PASSWORD:-aperonight}"
|
||||||
|
# RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
#ports:
|
||||||
|
# - "3306:3306"
|
||||||
|
#options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
options: >-
|
||||||
|
--health-cmd="healthcheck.sh --connect --innodb_initialized"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=3
|
||||||
|
env:
|
||||||
|
RAILS_ENV: test
|
||||||
|
DB_HOST: mariadb
|
||||||
|
DB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||||
|
DB_DATABASE: "${DB_DATABASE:-aperonight_test}"
|
||||||
|
DB_USERNAME: "${DB_USERNAME:-root}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD:-root}"
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache # https://about.gitea.com/resources/tutorials/enable-gitea-actions-cache-to-accelerate-cicd
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
|
||||||
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
gem install bundler
|
||||||
|
bundle install --jobs 4 --retry 3
|
||||||
|
npm install -g yarn
|
||||||
|
yarn install
|
||||||
|
echo "📦 Dependencies installed!"
|
||||||
|
|
||||||
|
- name: Cache bundle
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/bundle
|
||||||
|
key: ${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||||
|
restore-keys: |-
|
||||||
|
${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||||
|
- name: Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/node_modules
|
||||||
|
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |-
|
||||||
|
${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: |
|
||||||
|
echo "🔄 Running migrations..."
|
||||||
|
bundle exec rails db:drop
|
||||||
|
bundle exec rails db:setup
|
||||||
|
bundle exec rails db:migrate
|
||||||
|
echo "🔄 Migrations complete!"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
echo "🧪 Running tests..."
|
||||||
|
bundle exec rails test
|
||||||
|
echo "🧪 Tests complete!"
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: |
|
||||||
|
echo "🚫 Running linter..."
|
||||||
|
bundle exec rubocop
|
||||||
|
echo "🚫 Linter complete!"
|
||||||
82
.gitea/workflows/ci-sqlite.yaml.old
Normal file
82
.gitea/workflows/ci-sqlite.yaml.old
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Ruby on Rails Test
|
||||||
|
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rails-test:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
env:
|
||||||
|
RAILS_ENV: test
|
||||||
|
# SQLite does not require these variables, but you can keep them for consistency
|
||||||
|
DB_TEST_ADAPTER: "sqlite3"
|
||||||
|
DB_TEST_DATABASE: "data/test.sqlite" # Default SQLite database file path
|
||||||
|
DB_TEST_USERNAME: "root"
|
||||||
|
DB_TEST_PASSWORD: "root"
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache # Optional, for caching
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
|
||||||
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
gem install bundler
|
||||||
|
bundle install --jobs 4 --retry 3
|
||||||
|
npm install -g yarn
|
||||||
|
yarn install
|
||||||
|
echo "📦 Dependencies installed!"
|
||||||
|
|
||||||
|
- name: Cache bundle
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/bundle
|
||||||
|
key: ${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||||
|
restore-keys: |-
|
||||||
|
${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/node_modules
|
||||||
|
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |-
|
||||||
|
${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: |
|
||||||
|
echo "🔄 Running migrations..."
|
||||||
|
bundle exec rails db:drop
|
||||||
|
bundle exec rails db:setup
|
||||||
|
bundle exec rails db:migrate
|
||||||
|
echo "🔄 Migrations complete!"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
echo "🧪 Running tests..."
|
||||||
|
bundle exec rails test
|
||||||
|
echo "🧪 Tests complete!"
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: |
|
||||||
|
echo "🚫 Running linter..."
|
||||||
|
bundle exec rubocop
|
||||||
|
echo "🚫 Linter complete!"
|
||||||
45
.gitea/workflows/linter.yaml
Normal file
45
.gitea/workflows/linter.yaml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Ruby on Rails Test
|
||||||
|
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rails-test:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
env:
|
||||||
|
RAILS_ENV: test
|
||||||
|
# SQLite does not require these variables, but you can keep them for consistency
|
||||||
|
DB_TEST_ADAPTER: "sqlite3"
|
||||||
|
DB_TEST_DATABASE: "data/test.sqlite" # Default SQLite database file path
|
||||||
|
DB_TEST_USERNAME: "root"
|
||||||
|
DB_TEST_PASSWORD: "root"
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache # Optional, for caching
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
|
||||||
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
gem install bundler
|
||||||
|
bundle install --jobs 4 --retry 3
|
||||||
|
npm install -g yarn
|
||||||
|
yarn install
|
||||||
|
echo "📦 Dependencies installed!"
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: |
|
||||||
|
echo "🚫 Running linter..."
|
||||||
|
bundle exec rubocop
|
||||||
|
echo "🚫 Linter complete!"
|
||||||
804
.superdesign/design_iterations/aperonight_design_system.css
Normal file
804
.superdesign/design_iterations/aperonight_design_system.css
Normal file
@@ -0,0 +1,804 @@
|
|||||||
|
/**
|
||||||
|
* Aperonight Design System
|
||||||
|
* Generated from homepage analysis
|
||||||
|
* A modern, professional design system for event platforms
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* === ROOT VARIABLES === */
|
||||||
|
:root {
|
||||||
|
/* Brand Colors */
|
||||||
|
--brand-primary: #667eea;
|
||||||
|
--brand-secondary: #764ba2;
|
||||||
|
--brand-accent: #facc15; /* yellow-400 */
|
||||||
|
--brand-accent-dark: #eab308; /* yellow-500 */
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--color-black: #000000;
|
||||||
|
--color-gray-50: #f9fafb;
|
||||||
|
--color-gray-100: #f3f4f6;
|
||||||
|
--color-gray-200: #e5e7eb;
|
||||||
|
--color-gray-300: #d1d5db;
|
||||||
|
--color-gray-400: #9ca3af;
|
||||||
|
--color-gray-500: #6b7280;
|
||||||
|
--color-gray-600: #4b5563;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
--color-gray-800: #1f2937;
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
|
||||||
|
/* Purple Shades */
|
||||||
|
--color-purple-600: #9333ea;
|
||||||
|
--color-purple-700: #7c3aed;
|
||||||
|
--color-purple-800: #6b21a8;
|
||||||
|
|
||||||
|
/* Blue Shades */
|
||||||
|
--color-blue-600: #2563eb;
|
||||||
|
--color-blue-700: #1d4ed8;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||||
|
--font-family-mono: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 1.875rem; /* 30px */
|
||||||
|
--text-4xl: 2.25rem; /* 36px */
|
||||||
|
--text-5xl: 3rem; /* 48px */
|
||||||
|
--text-6xl: 3.75rem; /* 60px */
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
--font-bold: 700;
|
||||||
|
|
||||||
|
/* Spacing Scale */
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-4: 1rem; /* 16px */
|
||||||
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-8: 2rem; /* 32px */
|
||||||
|
--space-12: 3rem; /* 48px */
|
||||||
|
--space-16: 4rem; /* 64px */
|
||||||
|
--space-24: 6rem; /* 96px */
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 0.375rem; /* 6px */
|
||||||
|
--radius-md: 0.5rem; /* 8px */
|
||||||
|
--radius-lg: 0.75rem; /* 12px */
|
||||||
|
--radius-xl: 1rem; /* 16px */
|
||||||
|
--radius-2xl: 1.25rem; /* 20px */
|
||||||
|
--radius-3xl: 1.5rem; /* 24px */
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||||
|
|
||||||
|
/* Gradients */
|
||||||
|
--gradient-primary: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
|
||||||
|
--gradient-overlay: rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: all 0.2s ease;
|
||||||
|
--transition-medium: all 0.3s ease;
|
||||||
|
--transition-slow: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BASE STYLES === */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
background-color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TYPOGRAPHY SYSTEM === */
|
||||||
|
.text-xs { font-size: var(--text-xs); }
|
||||||
|
.text-sm { font-size: var(--text-sm); }
|
||||||
|
.text-base { font-size: var(--text-base); }
|
||||||
|
.text-lg { font-size: var(--text-lg); }
|
||||||
|
.text-xl { font-size: var(--text-xl); }
|
||||||
|
.text-2xl { font-size: var(--text-2xl); }
|
||||||
|
.text-3xl { font-size: var(--text-3xl); }
|
||||||
|
.text-4xl { font-size: var(--text-4xl); }
|
||||||
|
.text-5xl { font-size: var(--text-5xl); }
|
||||||
|
.text-6xl { font-size: var(--text-6xl); }
|
||||||
|
|
||||||
|
.font-medium { font-weight: var(--font-medium); }
|
||||||
|
.font-semibold { font-weight: var(--font-semibold); }
|
||||||
|
.font-bold { font-weight: var(--font-bold); }
|
||||||
|
|
||||||
|
.leading-tight { line-height: 1.25; }
|
||||||
|
.leading-normal { line-height: 1.5; }
|
||||||
|
.leading-relaxed { line-height: 1.625; }
|
||||||
|
|
||||||
|
/* === BUTTON SYSTEM === */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-gray-100);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-white);
|
||||||
|
border: 2px solid var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background-color: var(--color-purple-600);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover {
|
||||||
|
background-color: var(--color-purple-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
background-color: var(--color-gray-900);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark:hover {
|
||||||
|
background-color: var(--color-gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Sizes */
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: var(--space-4) var(--space-8);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CARD SYSTEM === */
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image {
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event:hover .card-event-image img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-4);
|
||||||
|
left: var(--space-4);
|
||||||
|
background-color: var(--brand-accent);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-price {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-content {
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event:hover .card-event-title {
|
||||||
|
color: var(--color-purple-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-meta {
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-description {
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
max-width: 20rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === HERO SYSTEM === */
|
||||||
|
.hero {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--gradient-overlay);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-accent {
|
||||||
|
color: var(--brand-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Hero */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: var(--text-6xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === METRICS SYSTEM === */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-8);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-number {
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--color-purple-600);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.metric-number {
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SECTION SYSTEM === */
|
||||||
|
.section {
|
||||||
|
padding: var(--space-16) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === GRID SYSTEM === */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-1 { grid-template-columns: 1fr; }
|
||||||
|
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid-md-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid-md-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.grid-lg-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.grid-lg-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === UTILITY CLASSES === */
|
||||||
|
.container {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
padding-right: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
.bg-white { background-color: var(--color-white); }
|
||||||
|
.bg-gray-50 { background-color: var(--color-gray-50); }
|
||||||
|
.bg-gray-900 { background-color: var(--color-gray-900); }
|
||||||
|
|
||||||
|
.text-white { color: var(--color-white); }
|
||||||
|
.text-gray-600 { color: var(--color-gray-600); }
|
||||||
|
.text-gray-900 { color: var(--color-gray-900); }
|
||||||
|
|
||||||
|
.rounded-full { border-radius: var(--radius-full); }
|
||||||
|
.rounded-2xl { border-radius: var(--radius-2xl); }
|
||||||
|
|
||||||
|
.shadow-lg { box-shadow: var(--shadow-lg); }
|
||||||
|
.shadow-xl { box-shadow: var(--shadow-xl); }
|
||||||
|
|
||||||
|
.mb-2 { margin-bottom: var(--space-2); }
|
||||||
|
.mb-4 { margin-bottom: var(--space-4); }
|
||||||
|
.mb-6 { margin-bottom: var(--space-6); }
|
||||||
|
.mb-8 { margin-bottom: var(--space-8); }
|
||||||
|
.mb-12 { margin-bottom: var(--space-12); }
|
||||||
|
|
||||||
|
.p-4 { padding: var(--space-4); }
|
||||||
|
.p-6 { padding: var(--space-6); }
|
||||||
|
.p-8 { padding: var(--space-8); }
|
||||||
|
|
||||||
|
.flex { display: flex; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.gap-4 { gap: var(--space-4); }
|
||||||
|
|
||||||
|
.transition { transition: var(--transition-fast); }
|
||||||
|
|
||||||
|
.max-w-lg { max-width: 32rem; }
|
||||||
|
.max-w-2xl { max-width: 42rem; }
|
||||||
|
.max-w-4xl { max-width: 56rem; }
|
||||||
|
|
||||||
|
/* === BREADCRUMB SYSTEM === */
|
||||||
|
.breadcrumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
background-color: var(--color-white);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item a {
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item a:hover {
|
||||||
|
color: var(--color-purple-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
background: url("data:image/svg+xml,%3csvg fill='%234b5563' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' d='M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z' clip-rule='evenodd'/%3e%3c/svg%3e") center no-repeat;
|
||||||
|
background-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-current {
|
||||||
|
color: var(--color-purple-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === PAGE HEADER SYSTEM === */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: var(--space-8) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-meta {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === EVENTS GRID SYSTEM === */
|
||||||
|
.events-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.events-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.events-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-image {
|
||||||
|
height: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover .event-card-image img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-placeholder {
|
||||||
|
height: 12rem;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-placeholder svg {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-content {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-venue {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-date {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2) calc(var(--space-2) + var(--space-1));
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
background-color: rgba(147, 51, 234, 0.1);
|
||||||
|
color: var(--color-purple-800);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-description {
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-price {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-price-unavailable {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
color: var(--color-white);
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-link:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === EMPTY STATE SYSTEM === */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-16) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 6rem;
|
||||||
|
height: 6rem;
|
||||||
|
margin: 0 auto var(--space-6);
|
||||||
|
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1) 0%, rgba(79, 70, 229, 0.1) 100%);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon svg {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
color: var(--color-purple-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-description {
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
max-width: 24rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === PAGINATION SYSTEM === */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item {
|
||||||
|
margin: 0 var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
background-color: var(--color-white);
|
||||||
|
border: 1px solid var(--color-gray-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link:hover {
|
||||||
|
background-color: var(--color-gray-50);
|
||||||
|
border-color: var(--color-purple-300);
|
||||||
|
color: var(--color-purple-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.active .page-link {
|
||||||
|
background-color: var(--color-purple-600);
|
||||||
|
border-color: var(--color-purple-600);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.disabled .page-link {
|
||||||
|
color: var(--color-gray-300);
|
||||||
|
background-color: var(--color-white);
|
||||||
|
border-color: var(--color-gray-200);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === RESPONSIVE UTILITIES === */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sm\:flex-col { flex-direction: column; }
|
||||||
|
.sm\:text-center { text-align: center; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sm\:flex-row { flex-direction: row; }
|
||||||
|
.sm\:flex-1 { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.lg\:justify-start { justify-content: flex-start; }
|
||||||
|
.lg\:text-left { text-align: left; }
|
||||||
|
}
|
||||||
483
.superdesign/design_iterations/aperonight_design_system_1.html
Normal file
483
.superdesign/design_iterations/aperonight_design_system_1.html
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Aperonight Design System</title>
|
||||||
|
<link rel="stylesheet" href="aperonight_design_system.css">
|
||||||
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* Additional showcase styles */
|
||||||
|
.showcase-section {
|
||||||
|
padding: 3rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-circle {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--color-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-demo {
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--color-gray-50);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: var(--color-white);
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-gray-200);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--color-purple-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed secondary button for light backgrounds */
|
||||||
|
.btn-secondary-alt {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
border: 2px solid var(--color-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-alt:hover {
|
||||||
|
background-color: var(--color-gray-100);
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
border-color: var(--color-gray-400);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="container navbar-content">
|
||||||
|
<div class="logo">Aperonight Design System</div>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="#colors" class="nav-link">Couleurs</a></li>
|
||||||
|
<li><a href="#typography" class="nav-link">Typographie</a></li>
|
||||||
|
<li><a href="#buttons" class="nav-link">Boutons</a></li>
|
||||||
|
<li><a href="#cards" class="nav-link">Cartes</a></li>
|
||||||
|
<li><a href="#components" class="nav-link">Composants</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="hero-title">
|
||||||
|
Système de Design
|
||||||
|
<span class="hero-accent">Aperonight</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
Un système de design moderne et cohérent pour créer des expériences exceptionnelles dans le domaine des événements après-travail.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<a href="#colors" class="btn btn-primary btn-lg">
|
||||||
|
<i data-lucide="palette" class="w-5 h-5"></i>
|
||||||
|
Explorer les Composants
|
||||||
|
</a>
|
||||||
|
<a href="#" class="btn btn-secondary btn-lg">
|
||||||
|
<i data-lucide="download" class="w-5 h-5"></i>
|
||||||
|
Télécharger
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Color Palette -->
|
||||||
|
<section id="colors" class="showcase-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Palette de Couleurs</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Les couleurs de base du système Aperonight, conçues pour transmettre professionnalisme et modernité.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="showcase-grid">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-semibold mb-4">Couleurs de Marque</h3>
|
||||||
|
<div class="grid grid-1 gap-4">
|
||||||
|
<div class="color-swatch">
|
||||||
|
<div class="color-circle" style="background: #667eea;"></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Primary Blue</div>
|
||||||
|
<div class="text-sm text-gray-600">#667eea</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-swatch">
|
||||||
|
<div class="color-circle" style="background: #764ba2;"></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Secondary Purple</div>
|
||||||
|
<div class="text-sm text-gray-600">#764ba2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-swatch">
|
||||||
|
<div class="color-circle" style="background: #facc15;"></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Accent Yellow</div>
|
||||||
|
<div class="text-sm text-gray-600">#facc15</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-semibold mb-4">Couleurs Neutres</h3>
|
||||||
|
<div class="grid grid-1 gap-4">
|
||||||
|
<div class="color-swatch">
|
||||||
|
<div class="color-circle" style="background: #ffffff; border: 2px solid #e5e7eb;"></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">White</div>
|
||||||
|
<div class="text-sm text-gray-600">#ffffff</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-swatch">
|
||||||
|
<div class="color-circle" style="background: #f3f4f6;"></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Gray 100</div>
|
||||||
|
<div class="text-sm text-gray-600">#f3f4f6</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-swatch">
|
||||||
|
<div class="color-circle" style="background: #4b5563;"></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Gray 600</div>
|
||||||
|
<div class="text-sm text-gray-600">#4b5563</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-swatch">
|
||||||
|
<div class="color-circle" style="background: #111827;"></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Gray 900</div>
|
||||||
|
<div class="text-sm text-gray-600">#111827</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Typography -->
|
||||||
|
<section id="typography" class="showcase-section bg-gray-50">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Typographie</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Une hiérarchie typographique claire et lisible pour tous les contenus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="component-demo bg-white">
|
||||||
|
<h1 class="text-6xl font-bold mb-4">Hero Title - 60px Bold</h1>
|
||||||
|
<h2 class="text-4xl font-bold mb-4">Section Title - 36px Bold</h2>
|
||||||
|
<h3 class="text-2xl font-semibold mb-4">Card Title - 24px Semibold</h3>
|
||||||
|
<p class="text-xl mb-4">Large Text - 20px Regular</p>
|
||||||
|
<p class="text-base mb-4">Body Text - 16px Regular</p>
|
||||||
|
<p class="text-sm text-gray-600">Small Text - 14px Regular</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<section id="buttons" class="showcase-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Système de Boutons</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Différents styles de boutons pour diverses actions et hiérarchies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="component-demo">
|
||||||
|
<div class="grid grid-md-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Styles Principaux</h3>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i data-lucide="calendar"></i>
|
||||||
|
Bouton Principal
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary-alt">
|
||||||
|
<i data-lucide="user-plus"></i>
|
||||||
|
Bouton Secondaire
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-accent">
|
||||||
|
<i data-lucide="star"></i>
|
||||||
|
Bouton Accent
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-dark">
|
||||||
|
<i data-lucide="arrow-right"></i>
|
||||||
|
Bouton Sombre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Tailles</h3>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<button class="btn btn-primary btn-sm">
|
||||||
|
<i data-lucide="eye"></i>
|
||||||
|
Petit Bouton
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i data-lucide="calendar"></i>
|
||||||
|
Bouton Normal
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-lg">
|
||||||
|
<i data-lucide="search"></i>
|
||||||
|
Grand Bouton
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cards -->
|
||||||
|
<section id="cards" class="showcase-section bg-gray-50">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Système de Cartes</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Cartes événements et composants modulaires.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-md-2 lg:grid-lg-3 gap-8">
|
||||||
|
<!-- Event Card Example -->
|
||||||
|
<div class="card card-event">
|
||||||
|
<div class="card-event-image">
|
||||||
|
<img src="https://images.unsplash.com/photo-1511578314322-379afb476865?w=600&h=400&fit=crop" alt="Événement exemple">
|
||||||
|
<div class="card-event-badge">★ En vedette</div>
|
||||||
|
<div class="card-event-price">À partir de €25</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-event-content">
|
||||||
|
<h3 class="card-event-title">AFTERWORK ROOFTOP</h3>
|
||||||
|
<div class="card-event-meta mb-4">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-sm mb-2">
|
||||||
|
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||||
|
Vendredi 15 Décembre • 18:30
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center gap-2 text-sm">
|
||||||
|
<i data-lucide="map-pin" class="w-4 h-4"></i>
|
||||||
|
Rooftop Bar Paris
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="card-event-description">
|
||||||
|
Rejoignez-nous pour un afterwork exclusif avec vue panoramique sur Paris.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Simple Card -->
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-2">Carte Simple</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Une carte basique pour du contenu général avec hover effects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metric Card -->
|
||||||
|
<div class="card p-6 text-center">
|
||||||
|
<div class="metric-number">2.5k+</div>
|
||||||
|
<div class="metric-label">Membres Actifs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Components -->
|
||||||
|
<section id="components" class="showcase-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Composants UI</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Éléments d'interface réutilisables pour construire des expériences cohérentes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="showcase-grid">
|
||||||
|
<!-- Hero Component -->
|
||||||
|
<div class="component-demo">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Section Hero</h3>
|
||||||
|
<div class="hero" style="min-height: 300px; border-radius: var(--radius-2xl);">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="container text-center">
|
||||||
|
<h2 class="hero-title" style="font-size: var(--text-3xl);">
|
||||||
|
Titre <span class="hero-accent">Héro</span>
|
||||||
|
</h2>
|
||||||
|
<p class="hero-subtitle" style="font-size: var(--text-base); max-width: 24rem; margin: 0 auto var(--space-6);">
|
||||||
|
Description du héro avec gradient de fond
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary">Action Principale</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metrics Grid -->
|
||||||
|
<div class="component-demo">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Grille de Métriques</h3>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-item">
|
||||||
|
<div class="metric-number">50+</div>
|
||||||
|
<div class="metric-label">Événements</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<div class="metric-number">2.5k</div>
|
||||||
|
<div class="metric-label">Membres</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<div class="metric-number">12</div>
|
||||||
|
<div class="metric-label">Ce mois-ci</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<div class="metric-number">98%</div>
|
||||||
|
<div class="metric-label">Satisfaction</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Usage Guidelines -->
|
||||||
|
<section class="showcase-section bg-gray-50">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Guide d'Utilisation</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Principes et bonnes pratiques pour utiliser ce système de design.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-md-2 gap-8">
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">✨ Principes de Design</h3>
|
||||||
|
<ul class="space-y-3 text-gray-600">
|
||||||
|
<li><strong>Cohérence</strong> - Utilisez les composants de manière uniforme</li>
|
||||||
|
<li><strong>Accessibilité</strong> - Respectez les contrastes et la lisibilité</li>
|
||||||
|
<li><strong>Responsive</strong> - Adaptez à tous les écrans</li>
|
||||||
|
<li><strong>Performance</strong> - Optimisez les animations et interactions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">🎨 Utilisation des Couleurs</h3>
|
||||||
|
<ul class="space-y-3 text-gray-600">
|
||||||
|
<li><strong>Primary</strong> - Actions principales et navigation</li>
|
||||||
|
<li><strong>Accent</strong> - Éléments mis en évidence (badges, etc.)</li>
|
||||||
|
<li><strong>Gray</strong> - Textes, bordures et arrière-plans</li>
|
||||||
|
<li><strong>Purple</strong> - Métriques et éléments spéciaux</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-900 text-white py-16">
|
||||||
|
<div class="container text-center">
|
||||||
|
<h3 class="text-2xl font-bold mb-4">Système de Design Aperonight</h3>
|
||||||
|
<p class="text-gray-400 mb-8 max-w-2xl mx-auto">
|
||||||
|
Créé pour maintenir une expérience utilisateur cohérente et professionnelle à travers tous les points de contact Aperonight.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i data-lucide="download"></i>
|
||||||
|
Télécharger le CSS
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary">
|
||||||
|
<i data-lucide="github"></i>
|
||||||
|
Voir sur GitHub
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Initialize Lucide icons
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
// Smooth scrolling for navigation links
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||||
|
anchor.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.querySelector(this.getAttribute('href'));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add interaction effects
|
||||||
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
|
card.addEventListener('mouseenter', function() {
|
||||||
|
this.style.transform = 'translateY(-4px)';
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', function() {
|
||||||
|
this.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,738 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Connexion - AperoNight | Plateforme Événementielle Premium</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="aperonight_premium_light_theme.css">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-sans) !important;
|
|
||||||
background: var(--background) !important;
|
|
||||||
min-height: 100vh !important;
|
|
||||||
position: relative !important;
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
color: var(--foreground) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme background patterns */
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(circle at 2px 2px, var(--dot-color) 1px, transparent 1px);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
opacity: 0.3;
|
|
||||||
z-index: 0;
|
|
||||||
animation: dotFlow 30s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, transparent 48%, var(--connection-color) 50%, transparent 52%),
|
|
||||||
linear-gradient(0deg, transparent 48%, var(--connection-color) 50%, transparent 52%);
|
|
||||||
background-size: 100px 100px;
|
|
||||||
opacity: 0.12;
|
|
||||||
z-index: 0;
|
|
||||||
animation: connectionFlow 20s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dotFlow {
|
|
||||||
0% { transform: translate(0, 0); }
|
|
||||||
100% { transform: translate(40px, 40px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes connectionFlow {
|
|
||||||
0% { transform: translate(0, 0); }
|
|
||||||
100% { transform: translate(100px, 100px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page entrance orchestration */
|
|
||||||
.page-container {
|
|
||||||
animation: pageLoad 1000ms cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(50px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pageLoad {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Brand reveal animation */
|
|
||||||
.brand-container {
|
|
||||||
animation: brandReveal 1400ms ease-out 300ms forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes brandReveal {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium card elevation - light theme */
|
|
||||||
.login-card {
|
|
||||||
background: var(--glass-bg) !important;
|
|
||||||
backdrop-filter: var(--glass-backdrop) !important;
|
|
||||||
border: 1px solid var(--glass-border) !important;
|
|
||||||
border-radius: var(--radius-2xl) !important;
|
|
||||||
box-shadow: var(--shadow-2xl) !important;
|
|
||||||
animation: cardElevate 800ms cubic-bezier(0.34, 1.56, 0.64, 1) 600ms forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(40px);
|
|
||||||
transition: all 400ms ease-out;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
rgba(59, 130, 246, 0.05),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
transition: left 0.6s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card:hover::before {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card:hover {
|
|
||||||
transform: var(--hover-lift) var(--hover-scale);
|
|
||||||
box-shadow: var(--shadow-2xl), var(--shadow-electric);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cardElevate {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Professional input styling - light theme */
|
|
||||||
.input-group {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.25rem 3.5rem 1.25rem 1.25rem;
|
|
||||||
border: 2px solid var(--input-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--input);
|
|
||||||
color: var(--card-foreground);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 250ms ease-out;
|
|
||||||
outline: none;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: var(--shadow-electric), var(--focus-ring);
|
|
||||||
transform: scale(1.01);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus + .floating-label {
|
|
||||||
transform: translateY(-12px) scale(0.85);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-label {
|
|
||||||
position: absolute;
|
|
||||||
left: 1.25rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: var(--input);
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:not(:placeholder-shown) + .floating-label {
|
|
||||||
transform: translateY(-12px) scale(0.85);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Security toggle with premium feel - light theme */
|
|
||||||
.security-toggle {
|
|
||||||
position: absolute;
|
|
||||||
right: 1.25rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
opacity: 0.7;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.security-toggle:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--primary);
|
|
||||||
background: var(--primary-light);
|
|
||||||
transform: translateY(-50%) rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flat button styling - light theme */
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.25rem;
|
|
||||||
background: var(--primary) !important;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 300ms ease-out;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover {
|
|
||||||
transform: var(--hover-lift);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
background: var(--primary-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium ripple effect */
|
|
||||||
.login-button::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
transition: width 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
|
||||||
height 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:active::before {
|
|
||||||
width: 400px;
|
|
||||||
height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sophisticated checkbox - light theme */
|
|
||||||
.premium-checkbox {
|
|
||||||
appearance: none;
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
border: 2px solid var(--input-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--input);
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-checkbox:checked {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
animation: securityCheck 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-checkbox:checked::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 5px;
|
|
||||||
width: 6px;
|
|
||||||
height: 10px;
|
|
||||||
border: solid white;
|
|
||||||
border-width: 0 2px 2px 0;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes securityCheck {
|
|
||||||
0% { transform: scale(0); }
|
|
||||||
50% { transform: scale(1.2); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Professional link styling - light theme */
|
|
||||||
.premium-link {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
position: relative;
|
|
||||||
transition: all 250ms ease-out;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-link::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -2px;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--accent);
|
|
||||||
transition: width 250ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-link:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-link:hover::after {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Validation states - light theme */
|
|
||||||
.input-error {
|
|
||||||
border-color: var(--destructive) !important;
|
|
||||||
animation: errorShake 400ms cubic-bezier(0.36, 0, 0.66, -0.56);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes errorShake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-10px); }
|
|
||||||
75% { transform: translateX(10px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-success {
|
|
||||||
border-color: var(--success) !important;
|
|
||||||
box-shadow: 0 0 0 3px var(--success-light);
|
|
||||||
animation: validationSuccess 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes validationSuccess {
|
|
||||||
0% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.02); border-color: var(--success); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium loading states - light theme */
|
|
||||||
.skeleton {
|
|
||||||
background: var(--muted);
|
|
||||||
animation: skeletonPulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeletonPulse {
|
|
||||||
0%, 100% { opacity: 0.8; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logo styling - light theme */
|
|
||||||
.logo-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-glow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trust indicators - light theme */
|
|
||||||
.trust-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--success-light);
|
|
||||||
color: var(--success);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border: 1px solid var(--success);
|
|
||||||
transition: all 300ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trust-badge:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Professional footer - light theme */
|
|
||||||
.pro-footer {
|
|
||||||
background: rgba(59, 130, 246, 0.08);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme brand colors */
|
|
||||||
.brand-text-primary {
|
|
||||||
color: var(--primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text-secondary {
|
|
||||||
color: var(--primary-dark) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text-muted {
|
|
||||||
color: var(--muted-foreground) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link {
|
|
||||||
color: var(--primary) !important;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--primary-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
color: var(--muted-foreground) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive enhancements */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.login-card {
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field {
|
|
||||||
padding: 1rem 3rem 1rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Advanced accessibility */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* High contrast mode support */
|
|
||||||
@media (prefers-contrast: high) {
|
|
||||||
.login-card {
|
|
||||||
border: 3px solid var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus {
|
|
||||||
outline: 3px solid var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page-container relative z-10 flex items-center justify-center min-h-screen p-4">
|
|
||||||
<div class="w-full max-w-lg">
|
|
||||||
<!-- Premium Brand Section - Light Theme -->
|
|
||||||
<div class="brand-container text-center mb-10">
|
|
||||||
<div class="relative inline-block mb-6">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<div class="relative w-20 h-20 mx-auto bg-blue-600 rounded-2xl flex items-center justify-center logo-container">
|
|
||||||
<i data-lucide="calendar-check" class="w-10 h-10 text-white"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl font-bold brand-text-primary mb-2 font-display">AperoNight</h1>
|
|
||||||
<p class="brand-text-secondary text-lg font-medium mb-2">Plateforme Événementielle Premium</p>
|
|
||||||
<p class="brand-text-muted text-sm opacity-90">Connexion sécurisée • Interface professionnelle</p>
|
|
||||||
|
|
||||||
<div class="flex justify-center mt-4">
|
|
||||||
<div class="trust-badge">
|
|
||||||
<i data-lucide="shield-check" class="w-4 h-4"></i>
|
|
||||||
<span>Connexion Sécurisée</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Premium Login Card -->
|
|
||||||
<div class="login-card p-8">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800 mb-2 font-display">Accès Dashboard</h2>
|
|
||||||
<p class="text-gray-600 text-sm">Gérez vos événements en toute simplicité</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="space-y-6">
|
|
||||||
<!-- Email professionnel -->
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
class="input-field"
|
|
||||||
placeholder=" "
|
|
||||||
required
|
|
||||||
id="email"
|
|
||||||
>
|
|
||||||
<label class="floating-label" for="email">Email professionnel</label>
|
|
||||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mot de passe sécurisé -->
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
class="input-field"
|
|
||||||
placeholder=" "
|
|
||||||
required
|
|
||||||
id="password"
|
|
||||||
>
|
|
||||||
<label class="floating-label" for="password">Mot de passe sécurisé</label>
|
|
||||||
<button type="button" class="security-toggle" onclick="togglePassword()">
|
|
||||||
<i data-lucide="lock" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Options de connexion -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer group">
|
|
||||||
<input type="checkbox" class="premium-checkbox" id="remember">
|
|
||||||
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition-colors">
|
|
||||||
Maintenir la connexion
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-1 text-xs text-gray-500">
|
|
||||||
<i data-lucide="timer" class="w-3 h-3"></i>
|
|
||||||
<span>30 jours</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bouton de connexion premium -->
|
|
||||||
<button type="submit" class="login-button group">
|
|
||||||
<span class="relative z-10 flex items-center justify-center gap-2">
|
|
||||||
<i data-lucide="log-in" class="w-5 h-5"></i>
|
|
||||||
Accéder au Dashboard
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Options de récupération -->
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<a href="#" class="premium-link text-sm">Mot de passe oublié ?</a>
|
|
||||||
<div class="flex items-center justify-center space-x-4 text-xs text-gray-500">
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<i data-lucide="smartphone" class="w-3 h-3"></i>
|
|
||||||
2FA disponible
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<i data-lucide="key" class="w-3 h-3"></i>
|
|
||||||
SSO Enterprise
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Professional Footer - Light Theme -->
|
|
||||||
<div class="pro-footer text-center space-y-3">
|
|
||||||
<div class="flex items-center justify-center space-x-6 text-sm">
|
|
||||||
<a href="#" class="footer-link transition-colors flex items-center gap-1">
|
|
||||||
<i data-lucide="life-buoy" class="w-4 h-4"></i>
|
|
||||||
Support Pro
|
|
||||||
</a>
|
|
||||||
<a href="#" class="footer-link transition-colors flex items-center gap-1">
|
|
||||||
<i data-lucide="shield" class="w-4 h-4"></i>
|
|
||||||
Sécurité Renforcée
|
|
||||||
</a>
|
|
||||||
<a href="#" class="footer-link transition-colors flex items-center gap-1">
|
|
||||||
<i data-lucide="zap" class="w-4 h-4"></i>
|
|
||||||
API Premium
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs status-text">© 2024 AperoNight • Plateforme Événementielle Premium • Tous droits réservés</p>
|
|
||||||
<div class="flex items-center justify-center space-x-2 text-xs status-text">
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
||||||
Système opérationnel
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>99.9% uptime</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>GDPR compliant</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Initialize Lucide icons
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// Enhanced password toggle
|
|
||||||
function togglePassword() {
|
|
||||||
const passwordField = document.getElementById('password');
|
|
||||||
const toggleIcon = document.querySelector('.security-toggle i');
|
|
||||||
|
|
||||||
if (passwordField.type === 'password') {
|
|
||||||
passwordField.type = 'text';
|
|
||||||
toggleIcon.setAttribute('data-lucide', 'unlock');
|
|
||||||
} else {
|
|
||||||
passwordField.type = 'password';
|
|
||||||
toggleIcon.setAttribute('data-lucide', 'lock');
|
|
||||||
}
|
|
||||||
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Professional form validation
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
const emailField = document.getElementById('email');
|
|
||||||
const passwordField = document.getElementById('password');
|
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Reset states
|
|
||||||
emailField.classList.remove('input-error', 'input-success');
|
|
||||||
passwordField.classList.remove('input-error', 'input-success');
|
|
||||||
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
// Professional email validation
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(emailField.value)) {
|
|
||||||
emailField.classList.add('input-error');
|
|
||||||
isValid = false;
|
|
||||||
showNotification('Email invalide', 'error');
|
|
||||||
} else {
|
|
||||||
emailField.classList.add('input-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secure password validation
|
|
||||||
if (passwordField.value.length < 8) {
|
|
||||||
passwordField.classList.add('input-error');
|
|
||||||
isValid = false;
|
|
||||||
showNotification('Mot de passe trop court (min. 8 caractères)', 'error');
|
|
||||||
} else {
|
|
||||||
passwordField.classList.add('input-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValid) {
|
|
||||||
// Premium loading state
|
|
||||||
const button = document.querySelector('.login-button');
|
|
||||||
const originalContent = button.innerHTML;
|
|
||||||
button.innerHTML = `
|
|
||||||
<div class="flex items-center justify-center space-x-2">
|
|
||||||
<div class="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
|
|
||||||
<span>Connexion sécurisée...</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
showNotification('Connexion réussie ! Redirection...', 'success');
|
|
||||||
setTimeout(() => {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
}, 1500);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Real-time validation
|
|
||||||
emailField.addEventListener('input', function() {
|
|
||||||
this.classList.remove('input-error', 'input-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
passwordField.addEventListener('input', function() {
|
|
||||||
this.classList.remove('input-error', 'input-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Professional notification system
|
|
||||||
function showNotification(message, type) {
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = `
|
|
||||||
fixed top-4 right-4 z-50 p-4 rounded-lg shadow-2xl max-w-sm
|
|
||||||
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}
|
|
||||||
transform transition-all duration-300 ease-out translate-x-full
|
|
||||||
`;
|
|
||||||
notification.innerHTML = `
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}" class="w-5 h-5"></i>
|
|
||||||
<span class="font-medium">${message}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.transform = 'translateX(0)';
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.transform = 'translateX(100%)';
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(notification);
|
|
||||||
}, 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced floating label behavior
|
|
||||||
document.querySelectorAll('.input-field').forEach(input => {
|
|
||||||
input.addEventListener('focus', function() {
|
|
||||||
this.nextElementSibling.style.background = 'white';
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('blur', function() {
|
|
||||||
if (!this.value) {
|
|
||||||
this.nextElementSibling.style.background = 'var(--input)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Professional interaction tracking
|
|
||||||
console.log('🌟 AperoNight Premium Light Login Interface Loaded');
|
|
||||||
console.log('✅ Security features: 2FA, SSO, GDPR compliance');
|
|
||||||
console.log('🎨 Theme: Professional Event Platform - Light Mode');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
:root {
|
|
||||||
/* AperoNight Premium Light Theme - Professional Event Platform */
|
|
||||||
|
|
||||||
/* Base Colors - Clean Light Background with Professional Accents */
|
|
||||||
--background: oklch(0.9800 0.0050 240);
|
|
||||||
--foreground: oklch(0.1500 0.0200 240);
|
|
||||||
--surface: oklch(0.9600 0.0080 240);
|
|
||||||
--surface-elevated: oklch(0.9400 0.0120 240);
|
|
||||||
|
|
||||||
/* Card & Dialog surfaces */
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0.1500 0.0200 240);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0.1500 0.0200 240);
|
|
||||||
|
|
||||||
/* Primary - Professional Electric Blue */
|
|
||||||
--primary: oklch(0.5200 0.2200 220);
|
|
||||||
--primary-foreground: oklch(0.9900 0.0050 220);
|
|
||||||
--primary-hover: oklch(0.4600 0.2400 220);
|
|
||||||
--primary-light: oklch(0.9200 0.1000 220);
|
|
||||||
--primary-dark: oklch(0.3800 0.2600 220);
|
|
||||||
|
|
||||||
/* Secondary - Sophisticated Light Gray */
|
|
||||||
--secondary: oklch(0.9200 0.0100 240);
|
|
||||||
--secondary-foreground: oklch(0.3000 0.0300 240);
|
|
||||||
--secondary-hover: oklch(0.8800 0.0150 240);
|
|
||||||
|
|
||||||
/* Accent - Vibrant Cyan (Events Energy) */
|
|
||||||
--accent: oklch(0.6500 0.2400 200);
|
|
||||||
--accent-foreground: oklch(0.9900 0.0050 200);
|
|
||||||
--accent-light: oklch(0.9400 0.1200 200);
|
|
||||||
--accent-dark: oklch(0.5000 0.2800 200);
|
|
||||||
|
|
||||||
/* Success - Event Success Green */
|
|
||||||
--success: oklch(0.6000 0.2000 140);
|
|
||||||
--success-foreground: oklch(0.9800 0.0100 140);
|
|
||||||
--success-light: oklch(0.9600 0.0800 140);
|
|
||||||
|
|
||||||
/* Warning - Premium Amber */
|
|
||||||
--warning: oklch(0.7200 0.1800 60);
|
|
||||||
--warning-foreground: oklch(0.2500 0.0400 60);
|
|
||||||
--warning-light: oklch(0.9600 0.0800 60);
|
|
||||||
|
|
||||||
/* Error - Professional Red */
|
|
||||||
--destructive: oklch(0.5600 0.2200 20);
|
|
||||||
--destructive-foreground: oklch(0.9800 0.0100 20);
|
|
||||||
--destructive-light: oklch(0.9600 0.1000 20);
|
|
||||||
|
|
||||||
/* Muted tones */
|
|
||||||
--muted: oklch(0.9400 0.0100 240);
|
|
||||||
--muted-foreground: oklch(0.5200 0.0300 240);
|
|
||||||
--muted-dark: oklch(0.8800 0.0200 240);
|
|
||||||
|
|
||||||
/* Borders and inputs */
|
|
||||||
--border: oklch(0.8800 0.0200 240);
|
|
||||||
--input: oklch(0.9800 0.0080 240);
|
|
||||||
--input-border: oklch(0.8600 0.0300 240);
|
|
||||||
--ring: oklch(0.5200 0.2200 220);
|
|
||||||
|
|
||||||
/* Typography - Premium Event Platform */
|
|
||||||
--font-sans: 'Inter', 'Plus Jakarta Sans', system-ui, sans-serif;
|
|
||||||
--font-display: 'Space Grotesk', 'Outfit', system-ui, sans-serif;
|
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
|
|
||||||
/* Spacing and layout */
|
|
||||||
--radius: 0.75rem;
|
|
||||||
--spacing: 1rem;
|
|
||||||
|
|
||||||
/* Light theme shadow system */
|
|
||||||
--shadow-xs: 0 1px 3px 0 hsl(240 15% 15% / 0.08), 0 1px 2px -1px hsl(240 15% 15% / 0.06);
|
|
||||||
--shadow-sm: 0 2px 6px -1px hsl(240 15% 15% / 0.10), 0 2px 4px -2px hsl(240 15% 15% / 0.08);
|
|
||||||
--shadow: 0 4px 8px -2px hsl(240 15% 15% / 0.12), 0 2px 4px -2px hsl(240 15% 15% / 0.08);
|
|
||||||
--shadow-md: 0 8px 16px -4px hsl(240 15% 15% / 0.14), 0 4px 6px -2px hsl(240 15% 15% / 0.10);
|
|
||||||
--shadow-lg: 0 16px 24px -4px hsl(240 15% 15% / 0.16), 0 8px 8px -4px hsl(240 15% 15% / 0.08);
|
|
||||||
--shadow-xl: 0 20px 32px -8px hsl(240 15% 15% / 0.18), 0 8px 16px -8px hsl(240 15% 15% / 0.10);
|
|
||||||
--shadow-2xl: 0 32px 64px -12px hsl(240 15% 15% / 0.22);
|
|
||||||
|
|
||||||
/* Subtle accent shadows for light theme */
|
|
||||||
--shadow-electric: 0 4px 16px -2px hsl(220 80% 60% / 0.15), 0 2px 8px -2px hsl(220 80% 60% / 0.10);
|
|
||||||
--shadow-accent: 0 4px 16px -2px hsl(200 80% 60% / 0.18), 0 2px 8px -2px hsl(200 80% 60% / 0.12);
|
|
||||||
|
|
||||||
/* Light theme gradients */
|
|
||||||
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
|
||||||
--gradient-background: linear-gradient(135deg,
|
|
||||||
oklch(0.9800 0.0050 240) 0%,
|
|
||||||
oklch(0.9600 0.0080 235) 25%,
|
|
||||||
oklch(0.9400 0.0120 230) 50%,
|
|
||||||
oklch(0.9600 0.0080 225) 75%,
|
|
||||||
oklch(0.9800 0.0050 220) 100%);
|
|
||||||
--gradient-card: linear-gradient(135deg,
|
|
||||||
oklch(1.0000 0 0) 0%,
|
|
||||||
oklch(0.9900 0.0050 235) 100%);
|
|
||||||
|
|
||||||
/* Light theme pattern overlays */
|
|
||||||
--grid-color: oklch(0.8500 0.0300 240);
|
|
||||||
--dot-color: oklch(0.8000 0.0400 220);
|
|
||||||
--connection-color: oklch(0.7500 0.0800 210);
|
|
||||||
|
|
||||||
/* Light glassmorphism */
|
|
||||||
--glass-bg: oklch(1.0000 0 0 / 0.85);
|
|
||||||
--glass-border: oklch(0.8800 0.0200 240 / 0.25);
|
|
||||||
--glass-backdrop: blur(16px) saturate(180%);
|
|
||||||
|
|
||||||
/* Professional interaction states */
|
|
||||||
--hover-lift: translateY(-2px);
|
|
||||||
--hover-scale: scale(1.02);
|
|
||||||
--focus-ring: 0 0 0 3px var(--ring);
|
|
||||||
|
|
||||||
/* Event-specific colors for light theme */
|
|
||||||
--event-vip: oklch(0.6800 0.2200 45);
|
|
||||||
--event-premium: oklch(0.5800 0.2000 280);
|
|
||||||
--event-standard: oklch(0.6200 0.1600 160);
|
|
||||||
--event-available: oklch(0.6000 0.1800 140);
|
|
||||||
--event-limited: oklch(0.7000 0.1800 50);
|
|
||||||
--event-sold-out: oklch(0.5800 0.2000 15);
|
|
||||||
|
|
||||||
/* Radius variations */
|
|
||||||
--radius-xs: calc(var(--radius) - 4px);
|
|
||||||
--radius-sm: calc(var(--radius) - 2px);
|
|
||||||
--radius-md: var(--radius);
|
|
||||||
--radius-lg: calc(var(--radius) + 4px);
|
|
||||||
--radius-xl: calc(var(--radius) + 8px);
|
|
||||||
--radius-2xl: calc(var(--radius) + 12px);
|
|
||||||
--radius-full: 9999px;
|
|
||||||
}
|
|
||||||
@@ -1,710 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Connexion - AperoNight | Plateforme Événementielle Premium</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="aperonight_premium_theme.css">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-sans) !important;
|
|
||||||
background: oklch(0.1200 0.0300 240) !important;
|
|
||||||
min-height: 100vh !important;
|
|
||||||
position: relative !important;
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
color: var(--foreground) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Advanced background patterns */
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(circle at 2px 2px, var(--dot-color) 1px, transparent 1px);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
opacity: 0.4;
|
|
||||||
z-index: 0;
|
|
||||||
animation: dotFlow 30s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, transparent 48%, var(--connection-color) 50%, transparent 52%),
|
|
||||||
linear-gradient(0deg, transparent 48%, var(--connection-color) 50%, transparent 52%);
|
|
||||||
background-size: 100px 100px;
|
|
||||||
opacity: 0.15;
|
|
||||||
z-index: 0;
|
|
||||||
animation: connectionFlow 20s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dotFlow {
|
|
||||||
0% { transform: translate(0, 0); }
|
|
||||||
100% { transform: translate(40px, 40px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes connectionFlow {
|
|
||||||
0% { transform: translate(0, 0); }
|
|
||||||
100% { transform: translate(100px, 100px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page entrance orchestration */
|
|
||||||
.page-container {
|
|
||||||
animation: pageLoad 1000ms cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(50px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pageLoad {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Brand reveal animation */
|
|
||||||
.brand-container {
|
|
||||||
animation: brandReveal 1400ms ease-out 300ms forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes brandReveal {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium card elevation */
|
|
||||||
.login-card {
|
|
||||||
background: var(--glass-bg) !important;
|
|
||||||
backdrop-filter: var(--glass-backdrop) !important;
|
|
||||||
border: 1px solid var(--glass-border) !important;
|
|
||||||
border-radius: var(--radius-2xl) !important;
|
|
||||||
box-shadow: var(--shadow-2xl) !important;
|
|
||||||
animation: cardElevate 800ms cubic-bezier(0.34, 1.56, 0.64, 1) 600ms forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(40px);
|
|
||||||
transition: all 400ms ease-out;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.1),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
transition: left 0.6s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card:hover::before {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card:hover {
|
|
||||||
transform: var(--hover-lift) var(--hover-scale);
|
|
||||||
box-shadow: var(--shadow-2xl), var(--shadow-electric);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cardElevate {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Professional input styling */
|
|
||||||
.input-group {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.25rem 3.5rem 1.25rem 1.25rem;
|
|
||||||
border: 2px solid var(--input-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--input);
|
|
||||||
color: var(--card-foreground);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 250ms ease-out;
|
|
||||||
outline: none;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: var(--shadow-electric), var(--focus-ring);
|
|
||||||
transform: scale(1.01);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus + .floating-label {
|
|
||||||
transform: translateY(-12px) scale(0.85);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-label {
|
|
||||||
position: absolute;
|
|
||||||
left: 1.25rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: var(--input);
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:not(:placeholder-shown) + .floating-label {
|
|
||||||
transform: translateY(-12px) scale(0.85);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Security toggle with premium feel */
|
|
||||||
.security-toggle {
|
|
||||||
position: absolute;
|
|
||||||
right: 1.25rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
opacity: 0.7;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.security-toggle:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--primary);
|
|
||||||
background: var(--primary-light);
|
|
||||||
transform: translateY(-50%) rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flat button styling */
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.25rem;
|
|
||||||
background: var(--primary) !important;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 300ms ease-out;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover {
|
|
||||||
transform: var(--hover-lift);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
background: var(--primary-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium ripple effect */
|
|
||||||
.login-button::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
transition: width 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
|
||||||
height 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:active::before {
|
|
||||||
width: 400px;
|
|
||||||
height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sophisticated checkbox */
|
|
||||||
.premium-checkbox {
|
|
||||||
appearance: none;
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
border: 2px solid var(--input-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--input);
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-checkbox:checked {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
animation: securityCheck 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-checkbox:checked::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 5px;
|
|
||||||
width: 6px;
|
|
||||||
height: 10px;
|
|
||||||
border: solid white;
|
|
||||||
border-width: 0 2px 2px 0;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes securityCheck {
|
|
||||||
0% { transform: scale(0); }
|
|
||||||
50% { transform: scale(1.2); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Professional link styling */
|
|
||||||
.premium-link {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
position: relative;
|
|
||||||
transition: all 250ms ease-out;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-link::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -2px;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--accent);
|
|
||||||
transition: width 250ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-link:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-link:hover::after {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Validation states */
|
|
||||||
.input-error {
|
|
||||||
border-color: var(--destructive) !important;
|
|
||||||
animation: errorShake 400ms cubic-bezier(0.36, 0, 0.66, -0.56);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes errorShake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-10px); }
|
|
||||||
75% { transform: translateX(10px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-success {
|
|
||||||
border-color: var(--success) !important;
|
|
||||||
box-shadow: 0 0 0 3px var(--success-light);
|
|
||||||
animation: validationSuccess 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes validationSuccess {
|
|
||||||
0% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.02); border-color: var(--success); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium loading states */
|
|
||||||
.skeleton {
|
|
||||||
background: var(--muted);
|
|
||||||
animation: skeletonPulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeletonPulse {
|
|
||||||
0%, 100% { opacity: 0.8; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logo styling */
|
|
||||||
.logo-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-glow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trust indicators */
|
|
||||||
.trust-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--success-light);
|
|
||||||
color: var(--success);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border: 1px solid var(--success);
|
|
||||||
transition: all 300ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trust-badge:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Professional footer */
|
|
||||||
.pro-footer {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive enhancements */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.login-card {
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field {
|
|
||||||
padding: 1rem 3rem 1rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Advanced accessibility */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* High contrast mode support */
|
|
||||||
@media (prefers-contrast: high) {
|
|
||||||
.login-card {
|
|
||||||
border: 3px solid var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus {
|
|
||||||
outline: 3px solid var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page-container relative z-10 flex items-center justify-center min-h-screen p-4">
|
|
||||||
<div class="w-full max-w-lg">
|
|
||||||
<!-- Premium Brand Section -->
|
|
||||||
<div class="brand-container text-center mb-10">
|
|
||||||
<div class="relative inline-block mb-6">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<div class="relative w-20 h-20 mx-auto bg-blue-600 rounded-2xl flex items-center justify-center logo-container">
|
|
||||||
<i data-lucide="calendar-check" class="w-10 h-10 text-white"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2 font-display">AperoNight</h1>
|
|
||||||
<p class="text-blue-200 text-lg font-medium mb-2">Plateforme Événementielle Premium</p>
|
|
||||||
<p class="text-blue-300 text-sm opacity-90">Connexion sécurisée • Interface professionnelle</p>
|
|
||||||
|
|
||||||
<div class="flex justify-center mt-4">
|
|
||||||
<div class="trust-badge">
|
|
||||||
<i data-lucide="shield-check" class="w-4 h-4"></i>
|
|
||||||
<span>Connexion Sécurisée</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Premium Login Card -->
|
|
||||||
<div class="login-card p-8">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800 mb-2 font-display">Accès Dashboard</h2>
|
|
||||||
<p class="text-gray-600 text-sm">Gérez vos événements en toute simplicité</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="space-y-6">
|
|
||||||
<!-- Email professionnel -->
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
class="input-field"
|
|
||||||
placeholder=" "
|
|
||||||
required
|
|
||||||
id="email"
|
|
||||||
>
|
|
||||||
<label class="floating-label" for="email">Email professionnel</label>
|
|
||||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mot de passe sécurisé -->
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
class="input-field"
|
|
||||||
placeholder=" "
|
|
||||||
required
|
|
||||||
id="password"
|
|
||||||
>
|
|
||||||
<label class="floating-label" for="password">Mot de passe sécurisé</label>
|
|
||||||
<button type="button" class="security-toggle" onclick="togglePassword()">
|
|
||||||
<i data-lucide="lock" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Options de connexion -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer group">
|
|
||||||
<input type="checkbox" class="premium-checkbox" id="remember">
|
|
||||||
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition-colors">
|
|
||||||
Maintenir la connexion
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-1 text-xs text-gray-500">
|
|
||||||
<i data-lucide="timer" class="w-3 h-3"></i>
|
|
||||||
<span>30 jours</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bouton de connexion premium -->
|
|
||||||
<button type="submit" class="login-button group">
|
|
||||||
<span class="relative z-10 flex items-center justify-center gap-2">
|
|
||||||
<i data-lucide="log-in" class="w-5 h-5"></i>
|
|
||||||
Accéder au Dashboard
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Options de récupération -->
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<a href="#" class="premium-link text-sm">Mot de passe oublié ?</a>
|
|
||||||
<div class="flex items-center justify-center space-x-4 text-xs text-gray-500">
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<i data-lucide="smartphone" class="w-3 h-3"></i>
|
|
||||||
2FA disponible
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<i data-lucide="key" class="w-3 h-3"></i>
|
|
||||||
SSO Enterprise
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Professional Footer -->
|
|
||||||
<div class="pro-footer text-center space-y-3">
|
|
||||||
<div class="flex items-center justify-center space-x-6 text-sm text-blue-200">
|
|
||||||
<a href="#" class="hover:text-white transition-colors flex items-center gap-1">
|
|
||||||
<i data-lucide="life-buoy" class="w-4 h-4"></i>
|
|
||||||
Support Pro
|
|
||||||
</a>
|
|
||||||
<a href="#" class="hover:text-white transition-colors flex items-center gap-1">
|
|
||||||
<i data-lucide="shield" class="w-4 h-4"></i>
|
|
||||||
Sécurité Renforcée
|
|
||||||
</a>
|
|
||||||
<a href="#" class="hover:text-white transition-colors flex items-center gap-1">
|
|
||||||
<i data-lucide="zap" class="w-4 h-4"></i>
|
|
||||||
API Premium
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-blue-300">© 2024 AperoNight • Plateforme Événementielle Premium • Tous droits réservés</p>
|
|
||||||
<div class="flex items-center justify-center space-x-2 text-xs text-blue-400">
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
|
||||||
Système opérationnel
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>99.9% uptime</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>GDPR compliant</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Initialize Lucide icons
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// Enhanced password toggle
|
|
||||||
function togglePassword() {
|
|
||||||
const passwordField = document.getElementById('password');
|
|
||||||
const toggleIcon = document.querySelector('.security-toggle i');
|
|
||||||
|
|
||||||
if (passwordField.type === 'password') {
|
|
||||||
passwordField.type = 'text';
|
|
||||||
toggleIcon.setAttribute('data-lucide', 'unlock');
|
|
||||||
} else {
|
|
||||||
passwordField.type = 'password';
|
|
||||||
toggleIcon.setAttribute('data-lucide', 'lock');
|
|
||||||
}
|
|
||||||
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Professional form validation
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
const emailField = document.getElementById('email');
|
|
||||||
const passwordField = document.getElementById('password');
|
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Reset states
|
|
||||||
emailField.classList.remove('input-error', 'input-success');
|
|
||||||
passwordField.classList.remove('input-error', 'input-success');
|
|
||||||
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
// Professional email validation
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(emailField.value)) {
|
|
||||||
emailField.classList.add('input-error');
|
|
||||||
isValid = false;
|
|
||||||
showNotification('Email invalide', 'error');
|
|
||||||
} else {
|
|
||||||
emailField.classList.add('input-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secure password validation
|
|
||||||
if (passwordField.value.length < 8) {
|
|
||||||
passwordField.classList.add('input-error');
|
|
||||||
isValid = false;
|
|
||||||
showNotification('Mot de passe trop court (min. 8 caractères)', 'error');
|
|
||||||
} else {
|
|
||||||
passwordField.classList.add('input-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValid) {
|
|
||||||
// Premium loading state
|
|
||||||
const button = document.querySelector('.login-button');
|
|
||||||
const originalContent = button.innerHTML;
|
|
||||||
button.innerHTML = `
|
|
||||||
<div class="flex items-center justify-center space-x-2">
|
|
||||||
<div class="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
|
|
||||||
<span>Connexion sécurisée...</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
showNotification('Connexion réussie ! Redirection...', 'success');
|
|
||||||
setTimeout(() => {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
}, 1500);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Real-time validation
|
|
||||||
emailField.addEventListener('input', function() {
|
|
||||||
this.classList.remove('input-error', 'input-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
passwordField.addEventListener('input', function() {
|
|
||||||
this.classList.remove('input-error', 'input-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Professional notification system
|
|
||||||
function showNotification(message, type) {
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = `
|
|
||||||
fixed top-4 right-4 z-50 p-4 rounded-lg shadow-2xl max-w-sm
|
|
||||||
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}
|
|
||||||
transform transition-all duration-300 ease-out translate-x-full
|
|
||||||
`;
|
|
||||||
notification.innerHTML = `
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}" class="w-5 h-5"></i>
|
|
||||||
<span class="font-medium">${message}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.transform = 'translateX(0)';
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.transform = 'translateX(100%)';
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(notification);
|
|
||||||
}, 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced floating label behavior
|
|
||||||
document.querySelectorAll('.input-field').forEach(input => {
|
|
||||||
input.addEventListener('focus', function() {
|
|
||||||
this.nextElementSibling.style.background = 'white';
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('blur', function() {
|
|
||||||
if (!this.value) {
|
|
||||||
this.nextElementSibling.style.background = 'var(--input)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Professional interaction tracking
|
|
||||||
console.log('🚀 AperoNight Premium Login Interface Loaded');
|
|
||||||
console.log('✅ Security features: 2FA, SSO, GDPR compliance');
|
|
||||||
console.log('🎨 Theme: Professional Event Platform');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
:root {
|
|
||||||
/* AperoNight Premium Theme - Telecom Inspired */
|
|
||||||
|
|
||||||
/* Base Colors - Sophisticated Navy & Electric Accents */
|
|
||||||
--background: oklch(0.1200 0.0300 240);
|
|
||||||
--foreground: oklch(0.9500 0.0100 240);
|
|
||||||
--surface: oklch(0.1600 0.0400 240);
|
|
||||||
--surface-elevated: oklch(0.2000 0.0500 240);
|
|
||||||
|
|
||||||
/* Card & Dialog surfaces */
|
|
||||||
--card: oklch(0.9800 0.0100 240);
|
|
||||||
--card-foreground: oklch(0.1500 0.0200 240);
|
|
||||||
--popover: oklch(0.9800 0.0100 240);
|
|
||||||
--popover-foreground: oklch(0.1500 0.0200 240);
|
|
||||||
|
|
||||||
/* Primary - Premium Electric Blue */
|
|
||||||
--primary: oklch(0.5500 0.2400 220);
|
|
||||||
--primary-foreground: oklch(0.9800 0.0100 220);
|
|
||||||
--primary-hover: oklch(0.4800 0.2600 220);
|
|
||||||
--primary-light: oklch(0.8500 0.1200 220);
|
|
||||||
--primary-dark: oklch(0.3500 0.2800 220);
|
|
||||||
|
|
||||||
/* Secondary - Sophisticated Slate */
|
|
||||||
--secondary: oklch(0.8800 0.0200 240);
|
|
||||||
--secondary-foreground: oklch(0.2500 0.0300 240);
|
|
||||||
--secondary-hover: oklch(0.8200 0.0300 240);
|
|
||||||
|
|
||||||
/* Accent - Vibrant Cyan (Events Energy) */
|
|
||||||
--accent: oklch(0.6800 0.2600 200);
|
|
||||||
--accent-foreground: oklch(0.9800 0.0100 200);
|
|
||||||
--accent-light: oklch(0.8800 0.1400 200);
|
|
||||||
--accent-dark: oklch(0.4500 0.3000 200);
|
|
||||||
|
|
||||||
/* Success - Event Success Green */
|
|
||||||
--success: oklch(0.6200 0.2200 140);
|
|
||||||
--success-foreground: oklch(0.9600 0.0200 140);
|
|
||||||
--success-light: oklch(0.9200 0.1000 140);
|
|
||||||
|
|
||||||
/* Warning - Premium Gold */
|
|
||||||
--warning: oklch(0.7500 0.2000 60);
|
|
||||||
--warning-foreground: oklch(0.2000 0.0300 60);
|
|
||||||
--warning-light: oklch(0.9400 0.1000 60);
|
|
||||||
|
|
||||||
/* Error - Professional Red */
|
|
||||||
--destructive: oklch(0.5800 0.2400 20);
|
|
||||||
--destructive-foreground: oklch(0.9700 0.0200 20);
|
|
||||||
--destructive-light: oklch(0.9300 0.1200 20);
|
|
||||||
|
|
||||||
/* Muted tones */
|
|
||||||
--muted: oklch(0.8800 0.0200 240);
|
|
||||||
--muted-foreground: oklch(0.4800 0.0400 240);
|
|
||||||
--muted-dark: oklch(0.7500 0.0300 240);
|
|
||||||
|
|
||||||
/* Borders and inputs */
|
|
||||||
--border: oklch(0.8400 0.0300 240);
|
|
||||||
--input: oklch(0.9600 0.0200 240);
|
|
||||||
--input-border: oklch(0.8200 0.0400 240);
|
|
||||||
--ring: oklch(0.5500 0.2400 220);
|
|
||||||
|
|
||||||
/* Typography - Premium Event Platform */
|
|
||||||
--font-sans: 'Inter', 'Plus Jakarta Sans', system-ui, sans-serif;
|
|
||||||
--font-display: 'Space Grotesk', 'Outfit', system-ui, sans-serif;
|
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
|
|
||||||
/* Spacing and layout */
|
|
||||||
--radius: 0.75rem;
|
|
||||||
--spacing: 1rem;
|
|
||||||
|
|
||||||
/* Premium shadow system */
|
|
||||||
--shadow-xs: 0 1px 3px 0 hsl(240 30% 8% / 0.08), 0 1px 2px -1px hsl(240 30% 8% / 0.06);
|
|
||||||
--shadow-sm: 0 2px 6px -1px hsl(240 30% 8% / 0.10), 0 2px 4px -2px hsl(240 30% 8% / 0.08);
|
|
||||||
--shadow: 0 4px 8px -2px hsl(240 30% 8% / 0.12), 0 2px 4px -2px hsl(240 30% 8% / 0.08);
|
|
||||||
--shadow-md: 0 8px 16px -4px hsl(240 30% 8% / 0.14), 0 4px 6px -2px hsl(240 30% 8% / 0.10);
|
|
||||||
--shadow-lg: 0 16px 24px -4px hsl(240 30% 8% / 0.16), 0 8px 8px -4px hsl(240 30% 8% / 0.08);
|
|
||||||
--shadow-xl: 0 20px 32px -8px hsl(240 30% 8% / 0.20), 0 8px 16px -8px hsl(240 30% 8% / 0.12);
|
|
||||||
--shadow-2xl: 0 32px 64px -12px hsl(240 30% 8% / 0.25);
|
|
||||||
|
|
||||||
/* Electric/Glow shadows for premium effects */
|
|
||||||
--shadow-electric: 0 4px 16px -2px hsl(220 100% 70% / 0.20), 0 2px 8px -2px hsl(220 100% 70% / 0.15);
|
|
||||||
--shadow-accent: 0 4px 16px -2px hsl(200 100% 70% / 0.25), 0 2px 8px -2px hsl(200 100% 70% / 0.20);
|
|
||||||
|
|
||||||
/* Premium gradients */
|
|
||||||
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
|
||||||
--gradient-background: linear-gradient(135deg,
|
|
||||||
oklch(0.1200 0.0300 240) 0%,
|
|
||||||
oklch(0.1000 0.0400 235) 25%,
|
|
||||||
oklch(0.0800 0.0500 230) 50%,
|
|
||||||
oklch(0.1000 0.0400 225) 75%,
|
|
||||||
oklch(0.1200 0.0300 220) 100%);
|
|
||||||
--gradient-card: linear-gradient(135deg,
|
|
||||||
oklch(0.9900 0.0100 240) 0%,
|
|
||||||
oklch(0.9700 0.0200 235) 100%);
|
|
||||||
|
|
||||||
/* Tech pattern overlays */
|
|
||||||
--grid-color: oklch(0.3000 0.0500 240);
|
|
||||||
--dot-color: oklch(0.2500 0.0600 220);
|
|
||||||
--connection-color: oklch(0.4000 0.1200 210);
|
|
||||||
|
|
||||||
/* Glass morphism for premium feel */
|
|
||||||
--glass-bg: oklch(0.9800 0.0100 240 / 0.80);
|
|
||||||
--glass-border: oklch(0.8500 0.0300 240 / 0.30);
|
|
||||||
--glass-backdrop: blur(16px) saturate(200%);
|
|
||||||
|
|
||||||
/* Professional states */
|
|
||||||
--hover-lift: translateY(-2px);
|
|
||||||
--hover-scale: scale(1.02);
|
|
||||||
--focus-ring: 0 0 0 3px var(--ring);
|
|
||||||
|
|
||||||
/* Event-specific colors */
|
|
||||||
--event-vip: oklch(0.6500 0.2500 45);
|
|
||||||
--event-premium: oklch(0.5500 0.2200 280);
|
|
||||||
--event-standard: oklch(0.6000 0.1800 160);
|
|
||||||
--event-available: oklch(0.6200 0.2000 140);
|
|
||||||
--event-limited: oklch(0.7200 0.2000 50);
|
|
||||||
--event-sold-out: oklch(0.5500 0.2200 15);
|
|
||||||
|
|
||||||
/* Radius variations */
|
|
||||||
--radius-xs: calc(var(--radius) - 4px);
|
|
||||||
--radius-sm: calc(var(--radius) - 2px);
|
|
||||||
--radius-md: var(--radius);
|
|
||||||
--radius-lg: calc(var(--radius) + 4px);
|
|
||||||
--radius-xl: calc(var(--radius) + 8px);
|
|
||||||
--radius-2xl: calc(var(--radius) + 12px);
|
|
||||||
--radius-full: 9999px;
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dashboard - Minimalist Typography Design</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary: #1a1a1a;
|
|
||||||
--secondary: #6b7280;
|
|
||||||
--accent: #3b82f6;
|
|
||||||
--background: #fafafa;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--border: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
|
||||||
|
|
||||||
.minimal-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.minimal-card:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-number {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-subtle { color: var(--secondary); }
|
|
||||||
.bg-subtle { background-color: #f8fafc; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="min-h-screen">
|
|
||||||
<!-- Navigation -->
|
|
||||||
<nav class="border-b border-gray-200 bg-white">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between items-center h-16">
|
|
||||||
<div class="flex items-center space-x-8">
|
|
||||||
<h1 class="text-xl font-semibold">ApéroNight</h1>
|
|
||||||
<div class="flex space-x-6">
|
|
||||||
<a href="#" class="text-gray-900 border-b-2 border-blue-500 pb-1">Dashboard</a>
|
|
||||||
<a href="#" class="text-gray-500 hover:text-gray-900">Événements</a>
|
|
||||||
<a href="#" class="text-gray-500 hover:text-gray-900">Profil</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="p-2 rounded-lg hover:bg-gray-100">
|
|
||||||
<i data-lucide="bell" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-12 fade-in">
|
|
||||||
<h1 class="text-4xl font-bold mb-2">Bonjour, Marie</h1>
|
|
||||||
<p class="text-lg text-subtle">Voici un aperçu de vos activités et événements</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Critical Alert - Draft Tickets -->
|
|
||||||
<div class="minimal-card rounded-lg p-6 mb-8 border-l-4 border-orange-400 bg-orange-50 fade-in">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<div class="p-2 bg-orange-100 rounded-lg">
|
|
||||||
<i data-lucide="clock" class="w-5 h-5 text-orange-600"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-900 mb-1">Action requise</h3>
|
|
||||||
<p class="text-sm text-gray-600 mb-3">2 billets en attente de paiement expirent dans 25 minutes</p>
|
|
||||||
|
|
||||||
<!-- Ticket Details -->
|
|
||||||
<div class="bg-white rounded-lg p-3 mb-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-sm">Soirée Jazz au Sunset</span>
|
|
||||||
<span class="text-xs text-gray-500 ml-2">2 billets • €70</span>
|
|
||||||
</div>
|
|
||||||
<span class="mono text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded">1/3 tentatives</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-700 transition-colors">
|
|
||||||
Payer maintenant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metrics Grid -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-12 fade-in">
|
|
||||||
<div class="minimal-card rounded-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<span class="text-sm font-medium text-subtle">Réservations</span>
|
|
||||||
<i data-lucide="calendar-check" class="w-4 h-4 text-green-500"></i>
|
|
||||||
</div>
|
|
||||||
<div class="metric-number text-3xl text-gray-900 mb-1">05</div>
|
|
||||||
<div class="text-xs text-subtle">+2 ce mois</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="minimal-card rounded-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<span class="text-sm font-medium text-subtle">Aujourd'hui</span>
|
|
||||||
<i data-lucide="clock" class="w-4 h-4 text-blue-500"></i>
|
|
||||||
</div>
|
|
||||||
<div class="metric-number text-3xl text-gray-900 mb-1">03</div>
|
|
||||||
<div class="text-xs text-subtle">événements</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="minimal-card rounded-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<span class="text-sm font-medium text-subtle">Demain</span>
|
|
||||||
<i data-lucide="calendar" class="w-4 h-4 text-purple-500"></i>
|
|
||||||
</div>
|
|
||||||
<div class="metric-number text-3xl text-gray-900 mb-1">07</div>
|
|
||||||
<div class="text-xs text-subtle">événements</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="minimal-card rounded-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<span class="text-sm font-medium text-subtle">À venir</span>
|
|
||||||
<i data-lucide="trending-up" class="w-4 h-4 text-orange-500"></i>
|
|
||||||
</div>
|
|
||||||
<div class="metric-number text-3xl text-gray-900 mb-1">15</div>
|
|
||||||
<div class="text-xs text-subtle">cette semaine</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Sections -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
|
|
||||||
<!-- My Events -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<div class="minimal-card rounded-lg p-6 fade-in">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-semibold">Mes événements</h2>
|
|
||||||
<button class="text-accent text-sm font-medium hover:underline">Voir tout</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Event Row -->
|
|
||||||
<div class="flex items-center space-x-4 py-3 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div class="w-2 h-12 bg-red-400 rounded-full"></div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="font-medium">Concert Rock Alternative</h3>
|
|
||||||
<span class="mono text-xs bg-green-100 text-green-800 px-2 py-1 rounded">CONFIRMÉ</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-subtle">Aujourd'hui 21:00 • Salle Pleyel</p>
|
|
||||||
</div>
|
|
||||||
<button class="p-2 hover:bg-gray-100 rounded-lg">
|
|
||||||
<i data-lucide="download" class="w-4 h-4 text-gray-500"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-4 py-3 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div class="w-2 h-12 bg-blue-400 rounded-full"></div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="font-medium">Networking Tech</h3>
|
|
||||||
<span class="mono text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">DEMAIN</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-subtle">19:00 • WeWork République</p>
|
|
||||||
</div>
|
|
||||||
<button class="p-2 hover:bg-gray-100 rounded-lg">
|
|
||||||
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-4 py-3 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div class="w-2 h-12 bg-green-400 rounded-full"></div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="font-medium">Brunch du Dimanche</h3>
|
|
||||||
<span class="mono text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">DIMANCHE</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-subtle">11:00 • Café de Flore</p>
|
|
||||||
</div>
|
|
||||||
<button class="p-2 hover:bg-gray-100 rounded-lg">
|
|
||||||
<i data-lucide="calendar" class="w-4 h-4 text-gray-500"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Actions & Today -->
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="minimal-card rounded-lg p-6 fade-in">
|
|
||||||
<h3 class="font-semibold mb-4">Actions rapides</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<button class="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-left">
|
|
||||||
<div class="p-2 bg-blue-100 rounded-lg">
|
|
||||||
<i data-lucide="plus" class="w-4 h-4 text-blue-600"></i>
|
|
||||||
</div>
|
|
||||||
<span class="font-medium text-sm">Nouvel événement</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-left">
|
|
||||||
<div class="p-2 bg-green-100 rounded-lg">
|
|
||||||
<i data-lucide="search" class="w-4 h-4 text-green-600"></i>
|
|
||||||
</div>
|
|
||||||
<span class="font-medium text-sm">Rechercher</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-left">
|
|
||||||
<div class="p-2 bg-purple-100 rounded-lg">
|
|
||||||
<i data-lucide="heart" class="w-4 h-4 text-purple-600"></i>
|
|
||||||
</div>
|
|
||||||
<span class="font-medium text-sm">Favoris</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Today's Schedule -->
|
|
||||||
<div class="minimal-card rounded-lg p-6 fade-in">
|
|
||||||
<h3 class="font-semibold mb-4">Aujourd'hui</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<div class="mono text-xs bg-gray-100 px-2 py-1 rounded mt-1">14:00</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="font-medium text-sm">Cours de Cuisine</h4>
|
|
||||||
<p class="text-xs text-subtle">École Ducasse</p>
|
|
||||||
</div>
|
|
||||||
<span class="w-2 h-2 bg-yellow-400 rounded-full mt-2"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<div class="mono text-xs bg-gray-100 px-2 py-1 rounded mt-1">20:30</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="font-medium text-sm">Festival de Cinéma</h4>
|
|
||||||
<p class="text-xs text-subtle">MK2 Bibliothèque</p>
|
|
||||||
</div>
|
|
||||||
<span class="w-2 h-2 bg-red-400 rounded-full mt-2"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<div class="mono text-xs bg-gray-100 px-2 py-1 rounded mt-1">22:00</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="font-medium text-sm">Soirée Jazz</h4>
|
|
||||||
<p class="text-xs text-subtle">Le Sunset</p>
|
|
||||||
</div>
|
|
||||||
<span class="w-2 h-2 bg-blue-400 rounded-full mt-2"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<div class="minimal-card rounded-lg p-6 fade-in">
|
|
||||||
<h3 class="font-semibold mb-4">Statistiques</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-subtle">Total participations</span>
|
|
||||||
<span class="mono font-medium">127</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-subtle">Événements créés</span>
|
|
||||||
<span class="mono font-medium">12</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-subtle">Note moyenne</span>
|
|
||||||
<span class="mono font-medium">4.8/5</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upcoming Events Grid -->
|
|
||||||
<div class="mt-12">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-2xl font-semibold">Événements à venir</h2>
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<button class="text-sm text-subtle hover:text-gray-900 flex items-center space-x-1">
|
|
||||||
<i data-lucide="filter" class="w-4 h-4"></i>
|
|
||||||
<span>Filtrer</span>
|
|
||||||
</button>
|
|
||||||
<button class="text-sm text-subtle hover:text-gray-900 flex items-center space-x-1">
|
|
||||||
<i data-lucide="grid-3x3" class="w-4 h-4"></i>
|
|
||||||
<span>Vue</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<!-- Event Card -->
|
|
||||||
<div class="minimal-card rounded-lg overflow-hidden fade-in">
|
|
||||||
<div class="aspect-video bg-gradient-to-br from-purple-400 to-purple-600 flex items-center justify-center">
|
|
||||||
<i data-lucide="music" class="w-12 h-12 text-white"></i>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<h3 class="font-semibold">Concert Électro</h3>
|
|
||||||
<span class="mono text-xs bg-gray-100 px-2 py-1 rounded">€45</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-subtle mb-3">Samedi 21 Sept • Berghain</p>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">12 places restantes</span>
|
|
||||||
<button class="text-accent text-sm font-medium hover:underline">Réserver</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="minimal-card rounded-lg overflow-hidden fade-in">
|
|
||||||
<div class="aspect-video bg-gradient-to-br from-green-400 to-teal-600 flex items-center justify-center">
|
|
||||||
<i data-lucide="leaf" class="w-12 h-12 text-white"></i>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<h3 class="font-semibold">Marché Bio</h3>
|
|
||||||
<span class="mono text-xs bg-green-100 text-green-600 px-2 py-1 rounded">GRATUIT</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-subtle mb-3">Dimanche 22 Sept • Place des Vosges</p>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-blue-600 bg-blue-100 px-2 py-1 rounded">Accès libre</span>
|
|
||||||
<button class="text-accent text-sm font-medium hover:underline">Voir détails</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="minimal-card rounded-lg overflow-hidden fade-in">
|
|
||||||
<div class="aspect-video bg-gradient-to-br from-orange-400 to-red-600 flex items-center justify-center">
|
|
||||||
<i data-lucide="book-open" class="w-12 h-12 text-white"></i>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<h3 class="font-semibold">Salon du Livre</h3>
|
|
||||||
<span class="mono text-xs bg-gray-100 px-2 py-1 rounded">€15</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-subtle mb-3">Lundi 23 Sept • Grand Palais</p>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-yellow-600 bg-yellow-100 px-2 py-1 rounded">Populaire</span>
|
|
||||||
<button class="text-accent text-sm font-medium hover:underline">Réserver</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Load More -->
|
|
||||||
<div class="text-center mt-8">
|
|
||||||
<button class="text-accent font-medium hover:underline">Charger plus d'événements</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// Stagger animations
|
|
||||||
const fadeElements = document.querySelectorAll('.fade-in');
|
|
||||||
fadeElements.forEach((el, index) => {
|
|
||||||
el.style.animationDelay = `${index * 0.1}s`;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dashboard - Data Visualization Enhanced</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
|
|
||||||
.progress-ring {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-ring__circle {
|
|
||||||
transition: stroke-dashoffset 0.35s;
|
|
||||||
transform-origin: 50% 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-bg {
|
|
||||||
background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #06b6d4 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
height: 200px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-item::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -8px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-gray-50">
|
|
||||||
<div class="min-h-screen">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="gradient-bg px-6 py-8">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-4xl font-bold text-white mb-2">Dashboard Analytics</h1>
|
|
||||||
<p class="text-blue-100">Analyse détaillée de vos événements et participations</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<select class="bg-white/20 backdrop-blur-lg border border-white/30 rounded-lg px-4 py-2 text-white text-sm">
|
|
||||||
<option>7 derniers jours</option>
|
|
||||||
<option>30 derniers jours</option>
|
|
||||||
<option>3 derniers mois</option>
|
|
||||||
</select>
|
|
||||||
<button class="bg-white/20 backdrop-blur-lg border border-white/30 rounded-lg px-4 py-2 text-white text-sm font-medium hover:bg-white/30 transition-all">
|
|
||||||
<i data-lucide="download" class="w-4 h-4 inline mr-2"></i>
|
|
||||||
Exporter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KPI Cards with Progress -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<!-- Participation Rate -->
|
|
||||||
<div class="stat-card rounded-2xl p-6">
|
|
||||||
<div class="flex items-start justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-600">Taux de participation</h3>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 mt-2">87%</p>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
|
|
||||||
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
|
|
||||||
<circle class="progress-ring__circle" stroke="#10b981" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
|
|
||||||
stroke-dasharray="87 13" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
|
||||||
<i data-lucide="trending-up" class="w-6 h-6 text-green-600"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center text-sm">
|
|
||||||
<span class="text-green-600 font-medium">+5%</span>
|
|
||||||
<span class="text-gray-500 ml-1">vs. mois dernier</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Événements créés -->
|
|
||||||
<div class="stat-card rounded-2xl p-6">
|
|
||||||
<div class="flex items-start justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-600">Événements créés</h3>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 mt-2">12</p>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
|
|
||||||
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
|
|
||||||
<circle class="progress-ring__circle" stroke="#3b82f6" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
|
|
||||||
stroke-dasharray="60 40" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
|
||||||
<i data-lucide="plus-circle" class="w-6 h-6 text-blue-600"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center text-sm">
|
|
||||||
<span class="text-blue-600 font-medium">+3</span>
|
|
||||||
<span class="text-gray-500 ml-1">ce mois</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Revenus -->
|
|
||||||
<div class="stat-card rounded-2xl p-6">
|
|
||||||
<div class="flex items-start justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-600">Revenus</h3>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 mt-2">€2,340</p>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
|
|
||||||
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
|
|
||||||
<circle class="progress-ring__circle" stroke="#8b5cf6" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
|
|
||||||
stroke-dasharray="78 22" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
|
||||||
<i data-lucide="euro" class="w-6 h-6 text-purple-600"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center text-sm">
|
|
||||||
<span class="text-purple-600 font-medium">+18%</span>
|
|
||||||
<span class="text-gray-500 ml-1">vs. mois dernier</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Satisfaction -->
|
|
||||||
<div class="stat-card rounded-2xl p-6">
|
|
||||||
<div class="flex items-start justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-600">Satisfaction</h3>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 mt-2">4.8</p>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
|
|
||||||
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
|
|
||||||
<circle class="progress-ring__circle" stroke="#f59e0b" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
|
|
||||||
stroke-dasharray="96 4" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
|
||||||
<i data-lucide="star" class="w-6 h-6 text-yellow-500 fill-current"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center text-sm">
|
|
||||||
<span class="text-yellow-600 font-medium">+0.2</span>
|
|
||||||
<span class="text-gray-500 ml-1">vs. mois dernier</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="max-w-7xl mx-auto px-6 py-8">
|
|
||||||
|
|
||||||
<!-- Critical Alert -->
|
|
||||||
<div class="bg-red-50 border border-red-200 rounded-2xl p-6 mb-8">
|
|
||||||
<div class="flex items-start space-x-4">
|
|
||||||
<div class="p-3 bg-red-100 rounded-xl">
|
|
||||||
<i data-lucide="alert-circle" class="w-6 h-6 text-red-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-semibold text-red-900 mb-2">Paiements en attente - Action urgente</h3>
|
|
||||||
<p class="text-red-700 mb-4">2 billets expirent dans les 25 prochaines minutes</p>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl p-4 border border-red-200">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-semibold text-gray-900">Soirée Jazz au Sunset</h4>
|
|
||||||
<p class="text-sm text-gray-600">2 billets • Tentative 1/3</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="text-2xl font-bold text-gray-900">€70</p>
|
|
||||||
<div class="w-24 bg-red-200 rounded-full h-2 mt-1">
|
|
||||||
<div class="bg-red-600 h-2 rounded-full transition-all" style="width: 15%"></div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-red-600 mt-1">25min restantes</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-xl font-medium transition-colors">
|
|
||||||
Payer maintenant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Charts and Analytics -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
|
||||||
|
|
||||||
<!-- Event Participation Chart -->
|
|
||||||
<div class="bg-white rounded-2xl p-6 shadow-sm">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Participation aux événements</h3>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<button class="text-sm bg-blue-100 text-blue-700 px-3 py-1 rounded-full">7j</button>
|
|
||||||
<button class="text-sm text-gray-500 px-3 py-1 rounded-full">30j</button>
|
|
||||||
<button class="text-sm text-gray-500 px-3 py-1 rounded-full">3m</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="participationChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Event Categories Pie Chart -->
|
|
||||||
<div class="bg-white rounded-2xl p-6 shadow-sm">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Catégories d'événements</h3>
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="categoriesChart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-3 h-3 bg-blue-500 rounded-full"></div>
|
|
||||||
<span class="text-sm text-gray-600">Concert (40%)</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
|
||||||
<span class="text-sm text-gray-600">Cuisine (25%)</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
|
||||||
<span class="text-sm text-gray-600">Tech (20%)</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-3 h-3 bg-purple-500 rounded-full"></div>
|
|
||||||
<span class="text-sm text-gray-600">Art (15%)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timeline and Events -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
|
|
||||||
<!-- Event Timeline -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<div class="bg-white rounded-2xl p-6 shadow-sm">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Timeline des événements</h3>
|
|
||||||
<button class="text-blue-600 text-sm font-medium hover:underline">Voir tout</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute left-4 top-0 bottom-0 w-px bg-gray-200"></div>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Timeline Item -->
|
|
||||||
<div class="relative pl-10 pb-6">
|
|
||||||
<div class="timeline-item text-green-600">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-semibold text-gray-900">Concert Rock Alternative</h4>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Aujourd'hui 21:00 • Salle Pleyel</p>
|
|
||||||
<div class="flex items-center space-x-4 mt-2">
|
|
||||||
<div class="flex items-center text-xs">
|
|
||||||
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
|
|
||||||
<span>156 participants</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center text-xs">
|
|
||||||
<i data-lucide="star" class="w-3 h-3 mr-1 fill-current text-yellow-500"></i>
|
|
||||||
<span>4.7/5</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="bg-green-100 text-green-800 px-3 py-1 rounded-full text-xs font-medium">CONFIRMÉ</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative pl-10 pb-6">
|
|
||||||
<div class="timeline-item text-blue-600">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-semibold text-gray-900">Networking Tech</h4>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Demain 19:00 • WeWork République</p>
|
|
||||||
<div class="flex items-center space-x-4 mt-2">
|
|
||||||
<div class="flex items-center text-xs">
|
|
||||||
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
|
|
||||||
<span>42/50 participants</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-16 bg-gray-200 rounded-full h-1">
|
|
||||||
<div class="bg-blue-600 h-1 rounded-full" style="width: 84%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-xs font-medium">DEMAIN</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative pl-10 pb-6">
|
|
||||||
<div class="timeline-item text-purple-600">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-semibold text-gray-900">Brunch du Dimanche</h4>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Dimanche 11:00 • Café de Flore</p>
|
|
||||||
<div class="flex items-center space-x-4 mt-2">
|
|
||||||
<div class="flex items-center text-xs">
|
|
||||||
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
|
|
||||||
<span>8/12 participants</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-16 bg-gray-200 rounded-full h-1">
|
|
||||||
<div class="bg-purple-600 h-1 rounded-full" style="width: 67%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="bg-yellow-100 text-yellow-800 px-3 py-1 rounded-full text-xs font-medium">EN COURS</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative pl-10 pb-6">
|
|
||||||
<div class="timeline-item text-gray-400">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-semibold text-gray-900">Cours de Photographie</h4>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Mercredi 18:00 • Studio Martin</p>
|
|
||||||
<div class="flex items-center space-x-4 mt-2">
|
|
||||||
<div class="flex items-center text-xs">
|
|
||||||
<i data-lucide="calendar" class="w-3 h-3 mr-1"></i>
|
|
||||||
<span>Dans 3 jours</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-xs font-medium">PLANIFIÉ</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Sidebar -->
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Performance Metrics -->
|
|
||||||
<div class="bg-white rounded-2xl p-6 shadow-sm">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Performance</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600">Taux de réussite</span>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-20 bg-gray-200 rounded-full h-2">
|
|
||||||
<div class="bg-green-600 h-2 rounded-full" style="width: 94%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium">94%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600">Engagement</span>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-20 bg-gray-200 rounded-full h-2">
|
|
||||||
<div class="bg-blue-600 h-2 rounded-full" style="width: 78%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium">78%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600">Recommandations</span>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-20 bg-gray-200 rounded-full h-2">
|
|
||||||
<div class="bg-purple-600 h-2 rounded-full" style="width: 89%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium">89%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Top Categories -->
|
|
||||||
<div class="bg-white rounded-2xl p-6 shadow-sm">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Top catégories</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
||||||
<i data-lucide="music" class="w-4 h-4 text-blue-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium text-sm">Concert</span>
|
|
||||||
<span class="text-sm text-gray-500">40%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
|
|
||||||
<div class="bg-blue-600 h-1 rounded-full" style="width: 40%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
|
||||||
<i data-lucide="utensils" class="w-4 h-4 text-green-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium text-sm">Cuisine</span>
|
|
||||||
<span class="text-sm text-gray-500">25%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
|
|
||||||
<div class="bg-green-600 h-1 rounded-full" style="width: 25%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
|
||||||
<i data-lucide="laptop" class="w-4 h-4 text-yellow-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium text-sm">Tech</span>
|
|
||||||
<span class="text-sm text-gray-500">20%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
|
|
||||||
<div class="bg-yellow-600 h-1 rounded-full" style="width: 20%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
||||||
<i data-lucide="palette" class="w-4 h-4 text-purple-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium text-sm">Art</span>
|
|
||||||
<span class="text-sm text-gray-500">15%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
|
|
||||||
<div class="bg-purple-600 h-1 rounded-full" style="width: 15%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
|
||||||
<div class="bg-white rounded-2xl p-6 shadow-sm">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques rapides</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600">Événements créés</span>
|
|
||||||
<span class="font-medium">127</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600">Participants totaux</span>
|
|
||||||
<span class="font-medium">2,456</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600">Note moyenne</span>
|
|
||||||
<span class="font-medium">4.8/5</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600">Revenus</span>
|
|
||||||
<span class="font-medium">€12,340</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// Participation Chart
|
|
||||||
const participationCtx = document.getElementById('participationChart').getContext('2d');
|
|
||||||
new Chart(participationCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'],
|
|
||||||
datasets: [{
|
|
||||||
label: 'Participations',
|
|
||||||
data: [12, 19, 8, 15, 24, 18, 22],
|
|
||||||
borderColor: '#3b82f6',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Categories Chart
|
|
||||||
const categoriesCtx = document.getElementById('categoriesChart').getContext('2d');
|
|
||||||
new Chart(categoriesCtx, {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: ['Concert', 'Cuisine', 'Tech', 'Art'],
|
|
||||||
datasets: [{
|
|
||||||
data: [40, 25, 20, 15],
|
|
||||||
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'],
|
|
||||||
borderWidth: 0
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
/* ========================================
|
|
||||||
Dark Mode UI Framework
|
|
||||||
A beautiful dark mode design system
|
|
||||||
======================================== */
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
CSS Variables & Theme
|
|
||||||
======================================== */
|
|
||||||
:root {
|
|
||||||
/* Dark Mode Color Palette */
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
|
|
||||||
/* Spacing & Layout */
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--spacing-xs: 0.25rem;
|
|
||||||
--spacing-sm: 0.5rem;
|
|
||||||
--spacing-md: 0.75rem;
|
|
||||||
--spacing-lg: 1rem;
|
|
||||||
--spacing-xl: 1.5rem;
|
|
||||||
--spacing-2xl: 2rem;
|
|
||||||
--spacing-3xl: 3rem;
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
--font-size-xs: 0.75rem;
|
|
||||||
--font-size-sm: 0.875rem;
|
|
||||||
--font-size-base: 1rem;
|
|
||||||
--font-size-lg: 1.125rem;
|
|
||||||
--font-size-xl: 1.25rem;
|
|
||||||
--font-size-2xl: 1.5rem;
|
|
||||||
--font-size-3xl: 1.875rem;
|
|
||||||
--font-size-4xl: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Base Styles
|
|
||||||
======================================== */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: var(--font-family);
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Layout Components
|
|
||||||
======================================== */
|
|
||||||
.container {
|
|
||||||
max-width: 64rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: var(--spacing-2xl) var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-sm {
|
|
||||||
max-width: 42rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-lg {
|
|
||||||
max-width: 80rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
|
||||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
||||||
.grid-cols-auto { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
|
||||||
|
|
||||||
.gap-sm { gap: var(--spacing-sm); }
|
|
||||||
.gap-md { gap: var(--spacing-md); }
|
|
||||||
.gap-lg { gap: var(--spacing-lg); }
|
|
||||||
.gap-xl { gap: var(--spacing-xl); }
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-col {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-center {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-between {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Card Components
|
|
||||||
======================================== */
|
|
||||||
.card {
|
|
||||||
background-color: var(--card);
|
|
||||||
color: var(--card-foreground);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: calc(var(--radius) + 4px);
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Button Components
|
|
||||||
======================================== */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-lg);
|
|
||||||
min-height: 2.25rem;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: rgba(236, 236, 236, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover {
|
|
||||||
background-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-destructive {
|
|
||||||
background-color: var(--destructive);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-destructive:hover {
|
|
||||||
background-color: rgba(220, 38, 38, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button Sizes */
|
|
||||||
.btn-sm {
|
|
||||||
padding: var(--spacing-xs) var(--spacing-md);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
min-height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-lg {
|
|
||||||
padding: var(--spacing-md) var(--spacing-xl);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Form Components
|
|
||||||
======================================== */
|
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
border-color: var(--ring);
|
|
||||||
box-shadow: 0 0 0 3px rgba(136, 136, 136, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input::placeholder {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Badge Components
|
|
||||||
======================================== */
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid;
|
|
||||||
padding: 0.125rem var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Priority Badge Variants */
|
|
||||||
.badge-priority-high {
|
|
||||||
background: rgba(127, 29, 29, 0.3);
|
|
||||||
color: rgb(252, 165, 165);
|
|
||||||
border: 1px solid rgba(153, 27, 27, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-priority-medium {
|
|
||||||
background: rgba(120, 53, 15, 0.3);
|
|
||||||
color: rgb(252, 211, 77);
|
|
||||||
border: 1px solid rgba(146, 64, 14, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-priority-low {
|
|
||||||
background: rgba(20, 83, 45, 0.3);
|
|
||||||
color: rgb(134, 239, 172);
|
|
||||||
border: 1px solid rgba(22, 101, 52, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Tab Components
|
|
||||||
======================================== */
|
|
||||||
.tab-list {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
color: var(--foreground);
|
|
||||||
text-transform: capitalize;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
color: #1a1a1a !important;
|
|
||||||
border-color: #f8f9fa !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active:hover {
|
|
||||||
background-color: #e9ecef !important;
|
|
||||||
border-color: #e9ecef !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Typography
|
|
||||||
======================================== */
|
|
||||||
.text-xs { font-size: var(--font-size-xs); }
|
|
||||||
.text-sm { font-size: var(--font-size-sm); }
|
|
||||||
.text-base { font-size: var(--font-size-base); }
|
|
||||||
.text-lg { font-size: var(--font-size-lg); }
|
|
||||||
.text-xl { font-size: var(--font-size-xl); }
|
|
||||||
.text-2xl { font-size: var(--font-size-2xl); }
|
|
||||||
.text-3xl { font-size: var(--font-size-3xl); }
|
|
||||||
.text-4xl { font-size: var(--font-size-4xl); }
|
|
||||||
|
|
||||||
.font-normal { font-weight: 400; }
|
|
||||||
.font-medium { font-weight: 500; }
|
|
||||||
.font-semibold { font-weight: 600; }
|
|
||||||
.font-bold { font-weight: 700; }
|
|
||||||
|
|
||||||
.text-primary { color: var(--primary); }
|
|
||||||
.text-muted { color: var(--muted-foreground); }
|
|
||||||
.text-destructive { color: var(--destructive); }
|
|
||||||
|
|
||||||
.gradient-text {
|
|
||||||
background: linear-gradient(to right, var(--primary), rgba(236, 236, 236, 0.6));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Icon System
|
|
||||||
======================================== */
|
|
||||||
.icon {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
fill: currentColor;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-sm { width: 0.875rem; height: 0.875rem; }
|
|
||||||
.icon-lg { width: 1.25rem; height: 1.25rem; }
|
|
||||||
.icon-xl { width: 1.5rem; height: 1.5rem; }
|
|
||||||
.icon-2xl { width: 2rem; height: 2rem; }
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Interactive Components
|
|
||||||
======================================== */
|
|
||||||
.checkbox {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox:hover {
|
|
||||||
border-color: var(--ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox.checked {
|
|
||||||
background-color: rgb(22, 163, 74);
|
|
||||||
border-color: rgb(22, 163, 74);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox.checked::after {
|
|
||||||
content: '✓';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
List Components
|
|
||||||
======================================== */
|
|
||||||
.list-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-lg);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.025);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item.completed {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Empty State Component
|
|
||||||
======================================== */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-3xl) var(--spacing-lg);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state .icon {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
margin: 0 auto var(--spacing-lg);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Utility Classes
|
|
||||||
======================================== */
|
|
||||||
.hidden { display: none; }
|
|
||||||
.block { display: block; }
|
|
||||||
.flex { display: flex; }
|
|
||||||
.inline-flex { display: inline-flex; }
|
|
||||||
|
|
||||||
.w-full { width: 100%; }
|
|
||||||
.h-full { height: 100%; }
|
|
||||||
.min-h-screen { min-height: 100vh; }
|
|
||||||
|
|
||||||
.opacity-50 { opacity: 0.5; }
|
|
||||||
.opacity-60 { opacity: 0.6; }
|
|
||||||
.opacity-75 { opacity: 0.75; }
|
|
||||||
|
|
||||||
.transition-all { transition: all 0.2s ease; }
|
|
||||||
.transition-colors { transition: color 0.2s ease, background-color 0.2s ease; }
|
|
||||||
.transition-opacity { transition: opacity 0.2s ease; }
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Responsive Design
|
|
||||||
======================================== */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-cols-auto {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-col-mobile {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center-mobile {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap-sm-mobile { gap: var(--spacing-sm); }
|
|
||||||
|
|
||||||
.hidden-mobile { display: none; }
|
|
||||||
.block-mobile { display: block; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.text-2xl { font-size: var(--font-size-xl); }
|
|
||||||
.text-3xl { font-size: var(--font-size-2xl); }
|
|
||||||
.text-4xl { font-size: var(--font-size-3xl); }
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: var(--spacing-lg) var(--spacing-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Animation Utilities
|
|
||||||
======================================== */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
|
||||||
Focus & Accessibility
|
|
||||||
======================================== */
|
|
||||||
.focus-visible:focus-visible {
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
* {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Connexion - Quantic Telecom</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="quantic_telecom_theme.css">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-sans) !important;
|
|
||||||
background: var(--gradient-background) !important;
|
|
||||||
min-height: 100vh !important;
|
|
||||||
position: relative !important;
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background grid pattern */
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(var(--grid-color) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
|
||||||
background-size: 50px 50px;
|
|
||||||
opacity: 0.3;
|
|
||||||
z-index: 0;
|
|
||||||
animation: gridShift 20s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gridShift {
|
|
||||||
0% { transform: translate(0, 0); }
|
|
||||||
100% { transform: translate(50px, 50px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page entrance animation */
|
|
||||||
.page-container {
|
|
||||||
animation: pageLoad 800ms ease-out forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pageLoad {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logo animation */
|
|
||||||
.logo-container {
|
|
||||||
animation: logoFade 1200ms ease-out 200ms forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoFade {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card slide animation */
|
|
||||||
.login-card {
|
|
||||||
background: var(--glass-bg) !important;
|
|
||||||
backdrop-filter: var(--glass-backdrop) !important;
|
|
||||||
border: 1px solid var(--glass-border) !important;
|
|
||||||
border-radius: var(--radius-lg) !important;
|
|
||||||
box-shadow: var(--shadow-xl) !important;
|
|
||||||
animation: cardSlide 600ms cubic-bezier(0.4, 0, 0.2, 1) 400ms forwards;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
transition: all 300ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-2xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cardSlide {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input field styling */
|
|
||||||
.input-group {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem 3rem 1rem 1rem;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--input);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 3px var(--ring);
|
|
||||||
transform: scale(1.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:focus + .floating-label {
|
|
||||||
transform: translateY(-10px) scale(0.75);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-label {
|
|
||||||
position: absolute;
|
|
||||||
left: 1rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: var(--input);
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field:not(:placeholder-shown) + .floating-label {
|
|
||||||
transform: translateY(-10px) scale(0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Password toggle */
|
|
||||||
.password-toggle {
|
|
||||||
position: absolute;
|
|
||||||
right: 1rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 150ms ease-out;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.password-toggle:hover {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-50%) rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button styling */
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--gradient-primary) !important;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ripple effect */
|
|
||||||
.login-button::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
transition: width 400ms ease-out, height 400ms ease-out;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:active::before {
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox styling */
|
|
||||||
.custom-checkbox {
|
|
||||||
appearance: none;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--input);
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-checkbox:checked {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
animation: checkboxTick 250ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-checkbox:checked::before {
|
|
||||||
content: '✓';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes checkboxTick {
|
|
||||||
0% { transform: scale(0); }
|
|
||||||
50% { transform: scale(1.2); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Link styling */
|
|
||||||
.forgot-link {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
position: relative;
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forgot-link::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -2px;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--accent);
|
|
||||||
transition: width 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forgot-link:hover::after {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Validation states */
|
|
||||||
.input-error {
|
|
||||||
border-color: var(--destructive) !important;
|
|
||||||
animation: errorShake 300ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes errorShake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-8px); }
|
|
||||||
75% { transform: translateX(8px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-success {
|
|
||||||
border-color: var(--success) !important;
|
|
||||||
animation: successPulse 500ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes successPulse {
|
|
||||||
0% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.1); opacity: 0.8; }
|
|
||||||
100% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading states */
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(90deg, var(--muted) 25%, var(--accent) 50%, var(--muted) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: skeleton 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeleton {
|
|
||||||
0% { background-position: -200% 0; }
|
|
||||||
100% { background-position: 200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.login-card {
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page-container relative z-10 flex items-center justify-center min-h-screen p-4">
|
|
||||||
<div class="w-full max-w-md">
|
|
||||||
<!-- Logo and Header -->
|
|
||||||
<div class="logo-container text-center mb-8">
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="w-16 h-16 mx-auto bg-gradient-to-br from-blue-600 to-blue-800 rounded-xl flex items-center justify-center">
|
|
||||||
<i data-lucide="wifi" class="w-8 h-8 text-white"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Quantic Telecom</h1>
|
|
||||||
<p class="text-gray-600 text-sm">Connexion Espace Client</p>
|
|
||||||
<p class="text-gray-500 text-xs mt-1">Votre espace client sécurisé</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Card -->
|
|
||||||
<div class="login-card p-8">
|
|
||||||
<form class="space-y-6">
|
|
||||||
<!-- Email Field -->
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
class="input-field"
|
|
||||||
placeholder=" "
|
|
||||||
required
|
|
||||||
id="email"
|
|
||||||
>
|
|
||||||
<label class="floating-label" for="email">Adresse e-mail</label>
|
|
||||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Password Field -->
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
class="input-field"
|
|
||||||
placeholder=" "
|
|
||||||
required
|
|
||||||
id="password"
|
|
||||||
>
|
|
||||||
<label class="floating-label" for="password">Mot de passe</label>
|
|
||||||
<button type="button" class="password-toggle" onclick="togglePassword()">
|
|
||||||
<i data-lucide="eye" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remember Me -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input type="checkbox" class="custom-checkbox" id="remember">
|
|
||||||
<span class="text-sm text-gray-700">Se souvenir de moi</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Button -->
|
|
||||||
<button type="submit" class="login-button">
|
|
||||||
<span class="relative z-10">SE CONNECTER</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Forgot Password -->
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="#" class="forgot-link text-sm">Mot de passe oublié ?</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Support Footer -->
|
|
||||||
<div class="text-center mt-8 space-y-2">
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Besoin d'aide ?
|
|
||||||
<a href="#" class="text-blue-600 hover:text-blue-800 transition-colors">Support technique</a>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-400">© 2024 Quantic Telecom - Tous droits réservés</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Initialize Lucide icons
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// Password toggle functionality
|
|
||||||
function togglePassword() {
|
|
||||||
const passwordField = document.getElementById('password');
|
|
||||||
const toggleIcon = document.querySelector('.password-toggle i');
|
|
||||||
|
|
||||||
if (passwordField.type === 'password') {
|
|
||||||
passwordField.type = 'text';
|
|
||||||
toggleIcon.setAttribute('data-lucide', 'eye-off');
|
|
||||||
} else {
|
|
||||||
passwordField.type = 'password';
|
|
||||||
toggleIcon.setAttribute('data-lucide', 'eye');
|
|
||||||
}
|
|
||||||
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form validation
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
const emailField = document.getElementById('email');
|
|
||||||
const passwordField = document.getElementById('password');
|
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Reset validation states
|
|
||||||
emailField.classList.remove('input-error', 'input-success');
|
|
||||||
passwordField.classList.remove('input-error', 'input-success');
|
|
||||||
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
// Email validation
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(emailField.value)) {
|
|
||||||
emailField.classList.add('input-error');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
emailField.classList.add('input-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password validation
|
|
||||||
if (passwordField.value.length < 6) {
|
|
||||||
passwordField.classList.add('input-error');
|
|
||||||
isValid = false;
|
|
||||||
} else {
|
|
||||||
passwordField.classList.add('input-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValid) {
|
|
||||||
// Simulate login process
|
|
||||||
const button = document.querySelector('.login-button');
|
|
||||||
button.innerHTML = '<div class="flex items-center justify-center"><div class="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full mr-2"></div>Connexion...</div>';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
alert('Connexion réussie ! (Demo)');
|
|
||||||
button.innerHTML = '<span class="relative z-10">SE CONNECTER</span>';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Real-time input validation
|
|
||||||
emailField.addEventListener('input', function() {
|
|
||||||
this.classList.remove('input-error', 'input-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
passwordField.addEventListener('input', function() {
|
|
||||||
this.classList.remove('input-error', 'input-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add floating label behavior for better UX
|
|
||||||
document.querySelectorAll('.input-field').forEach(input => {
|
|
||||||
input.addEventListener('focus', function() {
|
|
||||||
this.nextElementSibling.classList.add('focused');
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('blur', function() {
|
|
||||||
if (!this.value) {
|
|
||||||
this.nextElementSibling.classList.remove('focused');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
:root {
|
|
||||||
/* Quantic Telecom Brand Colors */
|
|
||||||
--background: oklch(0.9800 0.0050 240);
|
|
||||||
--foreground: oklch(0.1500 0.0100 240);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0.1500 0.0100 240);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0.1500 0.0100 240);
|
|
||||||
|
|
||||||
/* Primary - Telecom Blue */
|
|
||||||
--primary: oklch(0.4800 0.2000 240);
|
|
||||||
--primary-foreground: oklch(0.9800 0.0050 240);
|
|
||||||
--primary-hover: oklch(0.4200 0.2200 240);
|
|
||||||
|
|
||||||
/* Secondary - Tech Gray */
|
|
||||||
--secondary: oklch(0.9200 0.0100 240);
|
|
||||||
--secondary-foreground: oklch(0.2500 0.0150 240);
|
|
||||||
|
|
||||||
/* Accent - Electric Blue */
|
|
||||||
--accent: oklch(0.6500 0.2800 220);
|
|
||||||
--accent-foreground: oklch(0.9800 0.0050 240);
|
|
||||||
|
|
||||||
/* Muted tones */
|
|
||||||
--muted: oklch(0.9600 0.0080 240);
|
|
||||||
--muted-foreground: oklch(0.4500 0.0120 240);
|
|
||||||
|
|
||||||
/* Success/Error states */
|
|
||||||
--success: oklch(0.5500 0.2000 140);
|
|
||||||
--success-foreground: oklch(0.9800 0.0050 140);
|
|
||||||
--destructive: oklch(0.5500 0.2200 20);
|
|
||||||
--destructive-foreground: oklch(0.9800 0.0050 20);
|
|
||||||
|
|
||||||
/* Borders and inputs */
|
|
||||||
--border: oklch(0.8800 0.0150 240);
|
|
||||||
--input: oklch(0.9600 0.0080 240);
|
|
||||||
--ring: oklch(0.4800 0.2000 240);
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-sans: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
|
||||||
--font-serif: 'Inter', 'Segoe UI', system-ui, serif;
|
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
|
|
||||||
/* Spacing and layout */
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--spacing: 1rem;
|
|
||||||
|
|
||||||
/* Modern shadows for depth */
|
|
||||||
--shadow-xs: 0 1px 3px 0 hsl(240 25% 3% / 0.06);
|
|
||||||
--shadow-sm: 0 1px 3px 0 hsl(240 25% 3% / 0.08), 0 1px 2px -1px hsl(240 25% 3% / 0.08);
|
|
||||||
--shadow: 0 4px 8px -2px hsl(240 25% 3% / 0.08), 0 2px 4px -2px hsl(240 25% 3% / 0.06);
|
|
||||||
--shadow-md: 0 8px 16px -4px hsl(240 25% 3% / 0.08), 0 4px 6px -2px hsl(240 25% 3% / 0.06);
|
|
||||||
--shadow-lg: 0 16px 24px -4px hsl(240 25% 3% / 0.08), 0 8px 8px -4px hsl(240 25% 3% / 0.04);
|
|
||||||
--shadow-xl: 0 20px 32px -8px hsl(240 25% 3% / 0.12), 0 8px 16px -8px hsl(240 25% 3% / 0.08);
|
|
||||||
|
|
||||||
/* Gradients for modern appeal */
|
|
||||||
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
|
||||||
--gradient-background: linear-gradient(135deg, oklch(0.9900 0.0030 240) 0%, oklch(0.9700 0.0080 220) 100%);
|
|
||||||
|
|
||||||
/* Grid overlay for tech aesthetic */
|
|
||||||
--grid-color: oklch(0.9400 0.0100 240);
|
|
||||||
|
|
||||||
/* Glass morphism effects */
|
|
||||||
--glass-bg: oklch(1.0000 0 0 / 0.70);
|
|
||||||
--glass-border: oklch(0.9000 0.0200 240 / 0.20);
|
|
||||||
--glass-backdrop: blur(12px) saturate(180%);
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 2px);
|
|
||||||
--radius-md: var(--radius);
|
|
||||||
--radius-lg: calc(var(--radius) + 4px);
|
|
||||||
--radius-xl: calc(var(--radius) + 8px);
|
|
||||||
}
|
|
||||||
117
AGENT.md
117
AGENT.md
@@ -12,22 +12,30 @@ This document provides technical details for AI agents working on the Aperonight
|
|||||||
- **Devise Integration**: Complete authentication system with registration, login, password reset
|
- **Devise Integration**: Complete authentication system with registration, login, password reset
|
||||||
- **Relationships**: Users can create events and purchase tickets
|
- **Relationships**: Users can create events and purchase tickets
|
||||||
- **Validations**: Email format, password strength, optional name fields
|
- **Validations**: Email format, password strength, optional name fields
|
||||||
|
- **Promoter System**: Professional accounts can create and manage events with Stripe integration
|
||||||
|
|
||||||
#### 2. Event System (`app/models/event.rb`)
|
#### 2. Event System (`app/models/event.rb`)
|
||||||
- **States**: `draft`, `published`, `canceled`, `sold_out` with enum management
|
- **States**: `draft`, `published`, `canceled`, `sold_out` with enum management
|
||||||
- **Geographic Data**: Latitude/longitude for venue mapping
|
- **Geographic Data**: Latitude/longitude for venue mapping
|
||||||
- **Relationships**: Belongs to user, has many ticket types and tickets through ticket types
|
- **Relationships**: Belongs to user, has many ticket types and tickets through ticket types
|
||||||
- **Scopes**: Featured events, published events, upcoming events with proper ordering
|
- **Scopes**: Featured events, published events, upcoming events with proper ordering
|
||||||
|
- **Payout Management**: Event-level payout tracking and status management
|
||||||
|
|
||||||
#### 3. Ticket Management
|
#### 3. Ticket Management
|
||||||
- **TicketType** (`app/models/ticket_type.rb`): Defines ticket categories with pricing, quantity, sale periods
|
- **TicketType** (`app/models/ticket_type.rb`): Defines ticket categories with pricing, quantity, sale periods
|
||||||
- **Ticket** (`app/models/ticket.rb`): Individual tickets with unique QR codes, status tracking, price storage
|
- **Ticket** (`app/models/ticket.rb`): Individual tickets with unique QR codes, status tracking, price storage
|
||||||
|
- **Order System** (`app/models/order.rb`): Groups tickets into orders with payment status tracking
|
||||||
|
|
||||||
#### 4. Payment Processing (`app/controllers/events_controller.rb`)
|
#### 4. Payment Processing (`app/controllers/events_controller.rb`)
|
||||||
- **Stripe Integration**: Complete checkout session creation and payment confirmation
|
- **Stripe Integration**: Complete checkout session creation and payment confirmation
|
||||||
- **Session Management**: Proper handling of payment success/failure with ticket generation
|
- **Session Management**: Proper handling of payment success/failure with ticket generation
|
||||||
- **Security**: Authentication required, cart validation, availability checking
|
- **Security**: Authentication required, cart validation, availability checking
|
||||||
|
|
||||||
|
#### 5. Financial System
|
||||||
|
- **Earnings** (`app/models/earning.rb`): Tracks revenue from paid orders, excluding refunded tickets
|
||||||
|
- **Payouts** (`app/models/payout.rb`): Manages promoter payout requests and processing
|
||||||
|
- **Platform Fees**: €0.50 fixed fee + 1.5% of ticket price, per ticket
|
||||||
|
|
||||||
### Database Schema Key Points
|
### Database Schema Key Points
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -38,6 +46,8 @@ CREATE TABLE users (
|
|||||||
encrypted_password varchar(255) NOT NULL,
|
encrypted_password varchar(255) NOT NULL,
|
||||||
first_name varchar(255),
|
first_name varchar(255),
|
||||||
last_name varchar(255),
|
last_name varchar(255),
|
||||||
|
is_professionnal boolean DEFAULT false, -- Professional account flag
|
||||||
|
stripe_connected_account_id varchar(255), -- Stripe Connect account for payouts
|
||||||
-- Devise fields: confirmation, reset tokens, etc.
|
-- Devise fields: confirmation, reset tokens, etc.
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,6 +65,8 @@ CREATE TABLE events (
|
|||||||
start_time datetime NOT NULL,
|
start_time datetime NOT NULL,
|
||||||
end_time datetime,
|
end_time datetime,
|
||||||
state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3
|
state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3
|
||||||
|
payout_status integer, -- enum: not_requested=0, requested=1, processing=2, completed=3, failed=4
|
||||||
|
payout_requested_at datetime,
|
||||||
featured boolean DEFAULT false,
|
featured boolean DEFAULT false,
|
||||||
image varchar(500)
|
image varchar(500)
|
||||||
);
|
);
|
||||||
@@ -73,14 +85,53 @@ CREATE TABLE ticket_types (
|
|||||||
minimum_age integer
|
minimum_age integer
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Orders group tickets and track payment status
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
user_id bigint REFERENCES users(id),
|
||||||
|
event_id bigint REFERENCES events(id),
|
||||||
|
status varchar(255) DEFAULT 'draft', -- draft, pending_payment, paid, completed, cancelled, expired
|
||||||
|
total_amount_cents integer DEFAULT 0,
|
||||||
|
payment_attempts integer DEFAULT 0,
|
||||||
|
expires_at datetime,
|
||||||
|
last_payment_attempt_at datetime
|
||||||
|
);
|
||||||
|
|
||||||
-- Individual tickets with QR codes
|
-- Individual tickets with QR codes
|
||||||
CREATE TABLE tickets (
|
CREATE TABLE tickets (
|
||||||
id bigint PRIMARY KEY,
|
id bigint PRIMARY KEY,
|
||||||
user_id bigint REFERENCES users(id),
|
order_id bigint REFERENCES orders(id),
|
||||||
ticket_type_id bigint REFERENCES ticket_types(id),
|
ticket_type_id bigint REFERENCES ticket_types(id),
|
||||||
qr_code varchar(255) UNIQUE NOT NULL,
|
qr_code varchar(255) UNIQUE NOT NULL,
|
||||||
price_cents integer NOT NULL,
|
price_cents integer NOT NULL,
|
||||||
status varchar(255) DEFAULT 'active' -- active, used, expired, refunded
|
status varchar(255) DEFAULT 'active', -- draft, active, used, expired, refunded
|
||||||
|
first_name varchar(255),
|
||||||
|
last_name varchar(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Earnings track revenue from paid orders
|
||||||
|
CREATE TABLE earnings (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
event_id bigint REFERENCES events(id),
|
||||||
|
user_id bigint REFERENCES users(id),
|
||||||
|
order_id bigint REFERENCES orders(id),
|
||||||
|
amount_cents integer, -- Promoter payout amount (after fees)
|
||||||
|
fee_cents integer, -- Platform fees
|
||||||
|
status integer DEFAULT 0, -- enum: pending=0, paid=1
|
||||||
|
stripe_payout_id varchar(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payouts track promoter payout requests
|
||||||
|
CREATE TABLE payouts (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
user_id bigint REFERENCES users(id),
|
||||||
|
event_id bigint REFERENCES events(id),
|
||||||
|
amount_cents integer NOT NULL, -- Gross amount
|
||||||
|
fee_cents integer NOT NULL DEFAULT 0, -- Platform fees
|
||||||
|
status integer DEFAULT 0, -- enum: pending=0, processing=1, completed=2, failed=3
|
||||||
|
stripe_payout_id varchar(255),
|
||||||
|
total_orders_count integer DEFAULT 0,
|
||||||
|
refunded_orders_count integer DEFAULT 0
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,6 +188,7 @@ session = Stripe::Checkout::Session.create({
|
|||||||
2. **Ticket Creation**: Generate tickets based on order items from metadata
|
2. **Ticket Creation**: Generate tickets based on order items from metadata
|
||||||
3. **QR Code Generation**: Automatic unique QR code creation via model callbacks
|
3. **QR Code Generation**: Automatic unique QR code creation via model callbacks
|
||||||
4. **Success Page**: Display tickets with download links
|
4. **Success Page**: Display tickets with download links
|
||||||
|
5. **Earnings Creation**: Automatically creates earnings records for promoter payout tracking
|
||||||
|
|
||||||
### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
|
### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
|
||||||
|
|
||||||
@@ -174,6 +226,61 @@ end
|
|||||||
- **Session Storage**: Preserves cart when redirecting to login
|
- **Session Storage**: Preserves cart when redirecting to login
|
||||||
- **Dynamic Updates**: Real-time cart total and ticket count updates
|
- **Dynamic Updates**: Real-time cart total and ticket count updates
|
||||||
|
|
||||||
|
## 🔄 Application Workflows
|
||||||
|
|
||||||
|
### 1. User Registration & Onboarding
|
||||||
|
1. User registers with email/password
|
||||||
|
2. Completes onboarding process to set up profile
|
||||||
|
3. Can browse and purchase tickets as a customer
|
||||||
|
|
||||||
|
### 2. Promoter Account Setup
|
||||||
|
1. User requests professional account status
|
||||||
|
2. Connects Stripe account for payment processing
|
||||||
|
3. Can create and manage events
|
||||||
|
|
||||||
|
### 3. Event Creation & Management
|
||||||
|
1. Promoter creates event in draft state
|
||||||
|
2. Adds ticket types with pricing and quantities
|
||||||
|
3. Publishes event to make it publicly available
|
||||||
|
4. Manages event status (publish/unpublish/cancel)
|
||||||
|
|
||||||
|
### 4. Ticket Purchase Flow
|
||||||
|
1. User adds tickets to cart
|
||||||
|
2. Proceeds to checkout with Stripe
|
||||||
|
3. Payment processing through Stripe
|
||||||
|
4. Order and ticket creation upon successful payment
|
||||||
|
5. Email confirmation sent to user
|
||||||
|
6. Automatic earnings record creation for promoter
|
||||||
|
|
||||||
|
### 5. Financial Workflows
|
||||||
|
|
||||||
|
#### Platform Fee Structure
|
||||||
|
- **Fixed Fee**: €0.50 per ticket
|
||||||
|
- **Percentage Fee**: 1.5% of ticket price per ticket
|
||||||
|
- **Calculation Example**:
|
||||||
|
- 1 ticket at €20.00: €0.50 + (€20.00 × 1.5%) = €0.50 + €0.30 = €0.80 total fees
|
||||||
|
- 3 tickets at €25.00 each: (3 × €0.50) + (3 × €25.00 × 1.5%) = €1.50 + €1.13 = €2.63 total fees
|
||||||
|
|
||||||
|
#### Earnings Tracking
|
||||||
|
1. When order is marked as paid, earnings record is automatically created
|
||||||
|
2. Earnings amount = Total ticket sales - Platform fees
|
||||||
|
3. Only non-refunded tickets are counted in earnings
|
||||||
|
4. Earnings remain in "pending" status until payout is requested
|
||||||
|
|
||||||
|
#### Payout Request Process
|
||||||
|
1. Event ends (current time >= event end_time)
|
||||||
|
2. Promoter requests payout through event management interface
|
||||||
|
3. System calculates total earnings for the event (excluding refunded tickets)
|
||||||
|
4. Creates payout record with gross amount, fees, and net amount
|
||||||
|
5. Updates event payout status to "requested"
|
||||||
|
6. Admin processes payout through Stripe
|
||||||
|
7. Payout status updated to "processing" then "completed" or "failed"
|
||||||
|
|
||||||
|
### 6. Refund Management
|
||||||
|
1. Tickets can be marked as refunded
|
||||||
|
2. Refunded tickets are excluded from earnings calculations
|
||||||
|
3. Promoters do not receive payouts for refunded tickets
|
||||||
|
|
||||||
## 🔧 Development Patterns
|
## 🔧 Development Patterns
|
||||||
|
|
||||||
### Model Validations
|
### Model Validations
|
||||||
@@ -231,6 +338,8 @@ RAILS_MASTER_KEY=...
|
|||||||
CREATE INDEX idx_events_published_start_time ON events (state, start_time);
|
CREATE INDEX idx_events_published_start_time ON events (state, start_time);
|
||||||
CREATE INDEX idx_tickets_user_status ON tickets (user_id, status);
|
CREATE INDEX idx_tickets_user_status ON tickets (user_id, status);
|
||||||
CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
|
CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
|
||||||
|
CREATE INDEX idx_orders_event_status ON orders (event_id, status);
|
||||||
|
CREATE INDEX idx_earnings_event_status ON earnings (event_id, status);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
@@ -238,6 +347,7 @@ CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
|
|||||||
- **Strong Parameters**: All user inputs filtered
|
- **Strong Parameters**: All user inputs filtered
|
||||||
- **Authentication**: Devise handles session security
|
- **Authentication**: Devise handles session security
|
||||||
- **Payment Security**: Stripe handles sensitive payment data
|
- **Payment Security**: Stripe handles sensitive payment data
|
||||||
|
- **Authorization**: Proper access controls for promoter vs customer actions
|
||||||
|
|
||||||
## 🧪 Testing Strategy
|
## 🧪 Testing Strategy
|
||||||
|
|
||||||
@@ -247,6 +357,7 @@ CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
|
|||||||
3. **Booking Process**: Cart validation, payment processing, ticket generation
|
3. **Booking Process**: Cart validation, payment processing, ticket generation
|
||||||
4. **PDF Generation**: QR code uniqueness, ticket format
|
4. **PDF Generation**: QR code uniqueness, ticket format
|
||||||
5. **Dashboard Metrics**: Query accuracy, performance
|
5. **Dashboard Metrics**: Query accuracy, performance
|
||||||
|
6. **Financial Workflows**: Fee calculations, payout processing, refund handling
|
||||||
|
|
||||||
### Seed Data Structure
|
### Seed Data Structure
|
||||||
```ruby
|
```ruby
|
||||||
@@ -279,7 +390,7 @@ ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)'
|
|||||||
ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby
|
ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby
|
||||||
|
|
||||||
# Mass rename across multiple files
|
# Mass rename across multiple files
|
||||||
ast-grep --pattern 'old_method_name($$$ARGS)' --rewrite 'new_method_name($$$ARGS)' --lang ruby --update-all
|
ast-grep --pattern 'old_method_name($$ARGS)' --rewrite 'new_method_name($$ARGS)' --lang ruby --update-all
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Best Practices:
|
#### Best Practices:
|
||||||
|
|||||||
83
BACKLOG.md
83
BACKLOG.md
@@ -2,43 +2,66 @@
|
|||||||
|
|
||||||
## 📋 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: Platform commission tracking and fee structure display
|
||||||
|
- [ ] feat: Tax reporting and revenue export for promoters
|
||||||
|
- [ ] feat: Event update notifications to ticket holders
|
||||||
|
- [ ] feat: Marketing tools with promotional codes and discounts
|
||||||
|
- [ ] feat: Customer support messaging between promoters and attendees
|
||||||
|
- [ ] feat: Attendance tracking (who showed up vs tickets sold)
|
||||||
|
- [ ] feat: Customer insights and demographics for promoters
|
||||||
|
- [ ] feat: Performance metrics and conversion rate analytics
|
||||||
|
- [ ] feat: Event templates for reusing successful formats
|
||||||
|
- [ ] feat: Staff management and role assignment for promoter teams
|
||||||
|
- [ ] feat: Multiple payment gateway options
|
||||||
|
- [ ] feat: Calendar sync (Google Calendar, Outlook integration)
|
||||||
|
- [ ] feat: Social media auto-posting for events
|
||||||
|
- [ ] feat: CRM and email marketing tool integrations
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
- [ ] feat: SMS integration for ticket delivery and updates
|
||||||
|
- [ ] feat: Mobile wallet integration
|
||||||
|
- [ ] feat: Multi-currency support
|
||||||
|
- [ ] feat: Event updates communication system
|
||||||
|
- [ ] feat: Bulk operations for group bookings
|
||||||
|
- [ ] feat: Fraud prevention and bot protection
|
||||||
|
- [ ] feat: Social login options
|
||||||
|
- [ ] feat: Event recommendations system
|
||||||
|
- [ ] feat: Invitation link. As organizer or promoter, you can invite people
|
||||||
|
|
||||||
## 🚧 Doing
|
## 🚧 Doing
|
||||||
|
|
||||||
- [ ] refactor: Moving checkout to OrdersController
|
- [x] feat: Payout system for promoters (automated/manual payment processing)
|
||||||
|
- [ ] feat: Payout tracking for administrators
|
||||||
|
- [ ] feat: Page to display all tickets for an event
|
||||||
|
- [ ] feat: Add a link into notification email to order page that display all tickets
|
||||||
|
|
||||||
## ✅ Done
|
## ✅ 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] Add login functionality
|
||||||
|
- [x] refactor: Moving checkout to OrdersController
|
||||||
|
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
|
||||||
|
- [x] feat: Digital tickets with QR codes
|
||||||
|
- [x] feat: Ticket inventory management and capacity limits
|
||||||
|
- [x] feat: Event discovery with search and filtering
|
||||||
|
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
||||||
|
- [x] style: Rewrite design system
|
||||||
|
- [x] refactor: Rewrite design mockup
|
||||||
|
|||||||
766
CLAUDE.md
766
CLAUDE.md
@@ -1,766 +0,0 @@
|
|||||||
When asked to design UI & frontend interface
|
|
||||||
# Role
|
|
||||||
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
|
|
||||||
Your goal is to help user generate amazing design using code
|
|
||||||
|
|
||||||
# Instructions
|
|
||||||
- Use the available tools when needed to help with file operations and code analysis
|
|
||||||
- When creating design file:
|
|
||||||
- Build one single html page of just one screen to build a design based on users' feedback/task
|
|
||||||
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
|
|
||||||
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
|
|
||||||
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
|
|
||||||
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
|
|
||||||
3. superdesign MUST generate responsive designs.
|
|
||||||
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
|
|
||||||
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
|
|
||||||
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
|
|
||||||
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
|
|
||||||
8. Example theme patterns:
|
|
||||||
Ney-brutalism style that feels like 90s web design
|
|
||||||
<neo-brutalism-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0 0 0);
|
|
||||||
--primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--secondary: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--secondary-foreground: oklch(0 0 0);
|
|
||||||
--muted: oklch(0.9551 0 0);
|
|
||||||
--muted-foreground: oklch(0.3211 0 0);
|
|
||||||
--accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--destructive: oklch(0 0 0);
|
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
|
||||||
--border: oklch(0 0 0);
|
|
||||||
--input: oklch(0 0 0);
|
|
||||||
--ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-1: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-2: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--chart-3: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--chart-4: oklch(0.7323 0.2492 142.4953);
|
|
||||||
--chart-5: oklch(0.5931 0.2726 328.3634);
|
|
||||||
--sidebar: oklch(0.9551 0 0);
|
|
||||||
--sidebar-foreground: oklch(0 0 0);
|
|
||||||
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--sidebar-accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-border: oklch(0 0 0);
|
|
||||||
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--font-sans: DM Sans, sans-serif;
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: Space Mono, monospace;
|
|
||||||
--radius: 0px;
|
|
||||||
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</neo-brutalism-style>
|
|
||||||
|
|
||||||
Modern dark mode style like vercel, linear
|
|
||||||
<modern-dark-mode-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.1450 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.1450 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.1450 0 0);
|
|
||||||
--primary: oklch(0.2050 0 0);
|
|
||||||
--primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--secondary: oklch(0.9700 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2050 0 0);
|
|
||||||
--muted: oklch(0.9700 0 0);
|
|
||||||
--muted-foreground: oklch(0.5560 0 0);
|
|
||||||
--accent: oklch(0.9700 0 0);
|
|
||||||
--accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--destructive: oklch(0.5770 0.2450 27.3250);
|
|
||||||
--destructive-foreground: oklch(1 0 0);
|
|
||||||
--border: oklch(0.9220 0 0);
|
|
||||||
--input: oklch(0.9220 0 0);
|
|
||||||
--ring: oklch(0.7080 0 0);
|
|
||||||
--chart-1: oklch(0.8100 0.1000 252);
|
|
||||||
--chart-2: oklch(0.6200 0.1900 260);
|
|
||||||
--chart-3: oklch(0.5500 0.2200 263);
|
|
||||||
--chart-4: oklch(0.4900 0.2200 264);
|
|
||||||
--chart-5: oklch(0.4200 0.1800 266);
|
|
||||||
--sidebar: oklch(0.9850 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1450 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2050 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9700 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--sidebar-border: oklch(0.9220 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7080 0 0);
|
|
||||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</modern-dark-mode-style>
|
|
||||||
|
|
||||||
## Images & icons
|
|
||||||
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
|
|
||||||
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
|
|
||||||
## Script
|
|
||||||
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
You should always follow workflow below unless user explicitly ask you to do something else:
|
|
||||||
1. Layout design
|
|
||||||
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
|
|
||||||
3. Core Animation design
|
|
||||||
4. Generate a singlehtml file for the UI
|
|
||||||
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
|
|
||||||
|
|
||||||
### 1. Layout design
|
|
||||||
Output type: Just text
|
|
||||||
Think through how should the layout of interface look like, what are different UI components
|
|
||||||
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
|
|
||||||
|
|
||||||
### 2. Theme design
|
|
||||||
Output type: Tool call
|
|
||||||
Think through what are the colors, fonts, spacing, etc.
|
|
||||||
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
### 3. Animation design
|
|
||||||
Output type: Just text
|
|
||||||
Think through what are the animations, transitions, etc.
|
|
||||||
|
|
||||||
### 4. Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Output type: Tool call
|
|
||||||
Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
|
|
||||||
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
<example>
|
|
||||||
<user>design an AI chat UI</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
|
|
||||||
|
|
||||||
## Core UI Components
|
|
||||||
|
|
||||||
**Header Area**
|
|
||||||
- Settings/menu button (Top left)
|
|
||||||
- Chat title/AI name (Top left)
|
|
||||||
- Conversation controls (new chat, clear, etc.) (Top right)
|
|
||||||
|
|
||||||
**Main Chat Area**
|
|
||||||
- Message container (scrollable) (Full width & height)
|
|
||||||
- User messages (typically right-aligned)
|
|
||||||
- AI messages (typically left-aligned)
|
|
||||||
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
|
|
||||||
|
|
||||||
**Input Area**
|
|
||||||
- Text input field (Full width left)
|
|
||||||
- Send button (Embed within the input field,Bottom right side of the screen)
|
|
||||||
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
|
|
||||||
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
|
|
||||||
|
|
||||||
|
|
||||||
## Layout Structure Options
|
|
||||||
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ ☰ HEADER BAR + │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ User Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [CHAT AREA] │
|
|
||||||
│ │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ [Text Input Field] [Send] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
|
|
||||||
When hamburger (☰) is clicked, sidebar slides out:
|
|
||||||
┌──────────────┬─────────────────────────────────────┐
|
|
||||||
│ SIDEBAR │ ☰ HEADER BAR + │
|
|
||||||
│ ┌──────────┐ ├─────────────────────────────────────┤
|
|
||||||
│ │ Chat 1 │ │ │
|
|
||||||
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ Chat 3 │ │ │ AI Message Bubble │ │
|
|
||||||
│ │ + New │ │ └─────────────────────────────┘ │
|
|
||||||
│ └──────────┘ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ User Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ AI Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ [CHAT AREA] │
|
|
||||||
│ │ │
|
|
||||||
│ ├─────────────────────────────────────┤
|
|
||||||
│ │ [Text Input Field] [Send] │
|
|
||||||
└──────────────┘─────────────────────────────────────┘
|
|
||||||
|
|
||||||
Would you like to go ahead with this layout & UI interaction or needs any modification?
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
They looks good
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the color & font theme,
|
|
||||||
|
|
||||||
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0.1448 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0.1448 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0.1448 0 0);
|
|
||||||
--primary: oklch(0.5555 0 0);
|
|
||||||
--primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--secondary: oklch(0.9702 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2046 0 0);
|
|
||||||
--muted: oklch(0.9702 0 0);
|
|
||||||
--muted-foreground: oklch(0.5486 0 0);
|
|
||||||
--accent: oklch(0.9702 0 0);
|
|
||||||
--accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--destructive: oklch(0.5830 0.2387 28.4765);
|
|
||||||
--destructive-foreground: oklch(0.9702 0 0);
|
|
||||||
--border: oklch(0.9219 0 0);
|
|
||||||
--input: oklch(0.9219 0 0);
|
|
||||||
--ring: oklch(0.7090 0 0);
|
|
||||||
--chart-1: oklch(0.5555 0 0);
|
|
||||||
--chart-2: oklch(0.5555 0 0);
|
|
||||||
--chart-3: oklch(0.5555 0 0);
|
|
||||||
--chart-4: oklch(0.5555 0 0);
|
|
||||||
--chart-5: oklch(0.5555 0 0);
|
|
||||||
--sidebar: oklch(0.9851 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1448 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2046 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9702 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--sidebar-border: oklch(0.9219 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7090 0 0);
|
|
||||||
--font-sans: Geist Mono, monospace;
|
|
||||||
--font-serif: Geist Mono, monospace;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--radius: 0rem;
|
|
||||||
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
}')
|
|
||||||
|
|
||||||
|
|
||||||
<user>
|
|
||||||
I like the vintage style
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the animation:
|
|
||||||
|
|
||||||
# CHAT UI ANIMATIONS - MICRO-SYNTAX
|
|
||||||
|
|
||||||
## Core Message Flow
|
|
||||||
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
|
|
||||||
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
|
|
||||||
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
|
|
||||||
status: 300ms ease-out [α0.6→1, S1→1.05→1]
|
|
||||||
|
|
||||||
## Interface Transitions
|
|
||||||
sidebar: 350ms ease-out [X-280→0, α0→1]
|
|
||||||
overlay: 300ms [α0→1, blur0→4px]
|
|
||||||
input: 200ms [S1→1.01, shadow+ring] focus
|
|
||||||
input: 150ms [S1.01→1, shadow-ring] blur
|
|
||||||
|
|
||||||
## Button Interactions
|
|
||||||
sendBtn: 150ms [S1→0.95→1, R±2°] press
|
|
||||||
sendBtn: 200ms [S1→1.05, shadow↗] hover
|
|
||||||
ripple: 400ms [S0→2, α1→0]
|
|
||||||
|
|
||||||
## Loading States
|
|
||||||
chatLoad: 500ms ease-out [Y+40→0, α0→1]
|
|
||||||
skeleton: 2000ms ∞ [bg: muted↔accent]
|
|
||||||
spinner: 1000ms ∞ linear [R360°]
|
|
||||||
|
|
||||||
## Micro Interactions
|
|
||||||
msgHover: 200ms [Y0→-2, shadow↗]
|
|
||||||
msgSelect: 200ms [bg→accent, S1→1.02]
|
|
||||||
error: 400ms [X±5] shake
|
|
||||||
success: 600ms bounce [S0→1.2→1, R360°]
|
|
||||||
|
|
||||||
## Scroll & Navigation
|
|
||||||
autoScroll: 400ms smooth
|
|
||||||
scrollHint: 800ms ∞×3 [Y±5]
|
|
||||||
|
|
||||||
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
|
|
||||||
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
This looks great, lets do it
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, I will generate the html file for each UI component and then combine them together to form a single html file
|
|
||||||
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
|
|
||||||
|
|
||||||
I've created the html design, please reveiw and let me know if you need any changes
|
|
||||||
|
|
||||||
</example>
|
|
||||||
|
|
||||||
IMPORTANT RULES:
|
|
||||||
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
|
|
||||||
2. You MUST confirm the layout, and then theme style, and then animation
|
|
||||||
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
|
|
||||||
4. You MUST create follow the workflow above
|
|
||||||
|
|
||||||
# Available Tools
|
|
||||||
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
|
|
||||||
- **write**: Write content to files in the workspace (creates parent directories automatically)
|
|
||||||
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
|
|
||||||
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
|
|
||||||
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
|
|
||||||
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
|
|
||||||
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
|
|
||||||
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
|
|
||||||
- **generateTheme**: Generate a theme for the design
|
|
||||||
|
|
||||||
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)
|
|
||||||
|
|
||||||
When asked to design UI & frontend interface
|
|
||||||
When asked to design UI & frontend interface
|
|
||||||
# Role
|
|
||||||
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
|
|
||||||
Your goal is to help user generate amazing design using code
|
|
||||||
|
|
||||||
# Instructions
|
|
||||||
- Use the available tools when needed to help with file operations and code analysis
|
|
||||||
- When creating design file:
|
|
||||||
- Build one single html page of just one screen to build a design based on users' feedback/task
|
|
||||||
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
|
|
||||||
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
|
|
||||||
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
|
|
||||||
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
|
|
||||||
3. superdesign MUST generate responsive designs.
|
|
||||||
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
|
|
||||||
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
|
|
||||||
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
|
|
||||||
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
|
|
||||||
8. Example theme patterns:
|
|
||||||
Ney-brutalism style that feels like 90s web design
|
|
||||||
<neo-brutalism-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0 0 0);
|
|
||||||
--primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--secondary: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--secondary-foreground: oklch(0 0 0);
|
|
||||||
--muted: oklch(0.9551 0 0);
|
|
||||||
--muted-foreground: oklch(0.3211 0 0);
|
|
||||||
--accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--destructive: oklch(0 0 0);
|
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
|
||||||
--border: oklch(0 0 0);
|
|
||||||
--input: oklch(0 0 0);
|
|
||||||
--ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-1: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-2: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--chart-3: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--chart-4: oklch(0.7323 0.2492 142.4953);
|
|
||||||
--chart-5: oklch(0.5931 0.2726 328.3634);
|
|
||||||
--sidebar: oklch(0.9551 0 0);
|
|
||||||
--sidebar-foreground: oklch(0 0 0);
|
|
||||||
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--sidebar-accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-border: oklch(0 0 0);
|
|
||||||
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--font-sans: DM Sans, sans-serif;
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: Space Mono, monospace;
|
|
||||||
--radius: 0px;
|
|
||||||
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</neo-brutalism-style>
|
|
||||||
|
|
||||||
Modern dark mode style like vercel, linear
|
|
||||||
<modern-dark-mode-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.1450 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.1450 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.1450 0 0);
|
|
||||||
--primary: oklch(0.2050 0 0);
|
|
||||||
--primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--secondary: oklch(0.9700 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2050 0 0);
|
|
||||||
--muted: oklch(0.9700 0 0);
|
|
||||||
--muted-foreground: oklch(0.5560 0 0);
|
|
||||||
--accent: oklch(0.9700 0 0);
|
|
||||||
--accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--destructive: oklch(0.5770 0.2450 27.3250);
|
|
||||||
--destructive-foreground: oklch(1 0 0);
|
|
||||||
--border: oklch(0.9220 0 0);
|
|
||||||
--input: oklch(0.9220 0 0);
|
|
||||||
--ring: oklch(0.7080 0 0);
|
|
||||||
--chart-1: oklch(0.8100 0.1000 252);
|
|
||||||
--chart-2: oklch(0.6200 0.1900 260);
|
|
||||||
--chart-3: oklch(0.5500 0.2200 263);
|
|
||||||
--chart-4: oklch(0.4900 0.2200 264);
|
|
||||||
--chart-5: oklch(0.4200 0.1800 266);
|
|
||||||
--sidebar: oklch(0.9850 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1450 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2050 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9700 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--sidebar-border: oklch(0.9220 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7080 0 0);
|
|
||||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</modern-dark-mode-style>
|
|
||||||
|
|
||||||
## Images & icons
|
|
||||||
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
|
|
||||||
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
|
|
||||||
## Script
|
|
||||||
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
You should always follow workflow below unless user explicitly ask you to do something else:
|
|
||||||
1. Layout design
|
|
||||||
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
|
|
||||||
3. Core Animation design
|
|
||||||
4. Generate a singlehtml file for the UI
|
|
||||||
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
|
|
||||||
|
|
||||||
### 1. Layout design
|
|
||||||
Output type: Just text
|
|
||||||
Think through how should the layout of interface look like, what are different UI components
|
|
||||||
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
|
|
||||||
|
|
||||||
### 2. Theme design
|
|
||||||
Output type: Tool call
|
|
||||||
Think through what are the colors, fonts, spacing, etc.
|
|
||||||
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
### 3. Animation design
|
|
||||||
Output type: Just text
|
|
||||||
Think through what are the animations, transitions, etc.
|
|
||||||
|
|
||||||
### 4. Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Output type: Tool call
|
|
||||||
Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
|
|
||||||
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
<example>
|
|
||||||
<user>design an AI chat UI</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
|
|
||||||
|
|
||||||
## Core UI Components
|
|
||||||
|
|
||||||
**Header Area**
|
|
||||||
- Settings/menu button (Top left)
|
|
||||||
- Chat title/AI name (Top left)
|
|
||||||
- Conversation controls (new chat, clear, etc.) (Top right)
|
|
||||||
|
|
||||||
**Main Chat Area**
|
|
||||||
- Message container (scrollable) (Full width & height)
|
|
||||||
- User messages (typically right-aligned)
|
|
||||||
- AI messages (typically left-aligned)
|
|
||||||
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
|
|
||||||
|
|
||||||
**Input Area**
|
|
||||||
- Text input field (Full width left)
|
|
||||||
- Send button (Embed within the input field,Bottom right side of the screen)
|
|
||||||
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
|
|
||||||
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
|
|
||||||
|
|
||||||
|
|
||||||
## Layout Structure Options
|
|
||||||
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ ☰ HEADER BAR + │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ User Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [CHAT AREA] │
|
|
||||||
│ │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ [Text Input Field] [Send] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
|
|
||||||
When hamburger (☰) is clicked, sidebar slides out:
|
|
||||||
┌──────────────┬─────────────────────────────────────┐
|
|
||||||
│ SIDEBAR │ ☰ HEADER BAR + │
|
|
||||||
│ ┌──────────┐ ├─────────────────────────────────────┤
|
|
||||||
│ │ Chat 1 │ │ │
|
|
||||||
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ Chat 3 │ │ │ AI Message Bubble │ │
|
|
||||||
│ │ + New │ │ └─────────────────────────────┘ │
|
|
||||||
│ └──────────┘ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ User Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ AI Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ [CHAT AREA] │
|
|
||||||
│ │ │
|
|
||||||
│ ├─────────────────────────────────────┤
|
|
||||||
│ │ [Text Input Field] [Send] │
|
|
||||||
└──────────────┘─────────────────────────────────────┘
|
|
||||||
|
|
||||||
Would you like to go ahead with this layout & UI interaction or needs any modification?
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
They looks good
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the color & font theme,
|
|
||||||
|
|
||||||
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0.1448 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0.1448 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0.1448 0 0);
|
|
||||||
--primary: oklch(0.5555 0 0);
|
|
||||||
--primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--secondary: oklch(0.9702 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2046 0 0);
|
|
||||||
--muted: oklch(0.9702 0 0);
|
|
||||||
--muted-foreground: oklch(0.5486 0 0);
|
|
||||||
--accent: oklch(0.9702 0 0);
|
|
||||||
--accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--destructive: oklch(0.5830 0.2387 28.4765);
|
|
||||||
--destructive-foreground: oklch(0.9702 0 0);
|
|
||||||
--border: oklch(0.9219 0 0);
|
|
||||||
--input: oklch(0.9219 0 0);
|
|
||||||
--ring: oklch(0.7090 0 0);
|
|
||||||
--chart-1: oklch(0.5555 0 0);
|
|
||||||
--chart-2: oklch(0.5555 0 0);
|
|
||||||
--chart-3: oklch(0.5555 0 0);
|
|
||||||
--chart-4: oklch(0.5555 0 0);
|
|
||||||
--chart-5: oklch(0.5555 0 0);
|
|
||||||
--sidebar: oklch(0.9851 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1448 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2046 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9702 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--sidebar-border: oklch(0.9219 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7090 0 0);
|
|
||||||
--font-sans: Geist Mono, monospace;
|
|
||||||
--font-serif: Geist Mono, monospace;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--radius: 0rem;
|
|
||||||
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
}')
|
|
||||||
|
|
||||||
|
|
||||||
<user>
|
|
||||||
I like the vintage style
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the animation:
|
|
||||||
|
|
||||||
# CHAT UI ANIMATIONS - MICRO-SYNTAX
|
|
||||||
|
|
||||||
## Core Message Flow
|
|
||||||
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
|
|
||||||
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
|
|
||||||
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
|
|
||||||
status: 300ms ease-out [α0.6→1, S1→1.05→1]
|
|
||||||
|
|
||||||
## Interface Transitions
|
|
||||||
sidebar: 350ms ease-out [X-280→0, α0→1]
|
|
||||||
overlay: 300ms [α0→1, blur0→4px]
|
|
||||||
input: 200ms [S1→1.01, shadow+ring] focus
|
|
||||||
input: 150ms [S1.01→1, shadow-ring] blur
|
|
||||||
|
|
||||||
## Button Interactions
|
|
||||||
sendBtn: 150ms [S1→0.95→1, R±2°] press
|
|
||||||
sendBtn: 200ms [S1→1.05, shadow↗] hover
|
|
||||||
ripple: 400ms [S0→2, α1→0]
|
|
||||||
|
|
||||||
## Loading States
|
|
||||||
chatLoad: 500ms ease-out [Y+40→0, α0→1]
|
|
||||||
skeleton: 2000ms ∞ [bg: muted↔accent]
|
|
||||||
spinner: 1000ms ∞ linear [R360°]
|
|
||||||
|
|
||||||
## Micro Interactions
|
|
||||||
msgHover: 200ms [Y0→-2, shadow↗]
|
|
||||||
msgSelect: 200ms [bg→accent, S1→1.02]
|
|
||||||
error: 400ms [X±5] shake
|
|
||||||
success: 600ms bounce [S0→1.2→1, R360°]
|
|
||||||
|
|
||||||
## Scroll & Navigation
|
|
||||||
autoScroll: 400ms smooth
|
|
||||||
scrollHint: 800ms ∞×3 [Y±5]
|
|
||||||
|
|
||||||
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
|
|
||||||
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
This looks great, lets do it
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, I will generate the html file for each UI component and then combine them together to form a single html file
|
|
||||||
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
|
|
||||||
|
|
||||||
I've created the html design, please reveiw and let me know if you need any changes
|
|
||||||
|
|
||||||
</example>
|
|
||||||
|
|
||||||
IMPORTANT RULES:
|
|
||||||
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
|
|
||||||
2. You MUST confirm the layout, and then theme style, and then animation
|
|
||||||
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
|
|
||||||
4. You MUST create follow the workflow above
|
|
||||||
|
|
||||||
# Available Tools
|
|
||||||
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
|
|
||||||
- **write**: Write content to files in the workspace (creates parent directories automatically)
|
|
||||||
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
|
|
||||||
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
|
|
||||||
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
|
|
||||||
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
|
|
||||||
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
|
|
||||||
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
|
|
||||||
- **generateTheme**: Generate a theme for the design
|
|
||||||
|
|
||||||
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)
|
|
||||||
51
CRUSH.md
51
CRUSH.md
@@ -1,51 +0,0 @@
|
|||||||
# Aperonight - CRUSH Development Guidelines
|
|
||||||
|
|
||||||
## Build Commands
|
|
||||||
- `bin/rails server` - Start development server
|
|
||||||
- `bin/rails assets:precompile` - Compile assets
|
|
||||||
- `npm run build` - Build JavaScript bundle (production)
|
|
||||||
- `npm run build:dev` - Build JavaScript bundle (development)
|
|
||||||
- `npm run build:css` - Compile CSS with PostCSS/Tailwind
|
|
||||||
|
|
||||||
## Test Commands
|
|
||||||
- `bin/rails test` - Run all tests
|
|
||||||
- `bin/rails test test/models/user_test.rb` - Run specific test file
|
|
||||||
- `bin/rails test test/models/user_test.rb:15` - Run specific test method
|
|
||||||
- `bin/rails test:system` - Run system tests
|
|
||||||
|
|
||||||
## Lint Commands
|
|
||||||
- `bin/rubocop` - Run Ruby linter
|
|
||||||
- `bin/rubocop -a` - Run Ruby linter with auto-fix
|
|
||||||
- Check JS/JSX files manually (no configured linter)
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
1. Branch naming: `type/descriptive-name` (e.g., `feature/user-profile`)
|
|
||||||
2. Follow Git Flow with `main` and `develop` branches
|
|
||||||
3. Run tests and linters before committing
|
|
||||||
4. Keep PRs focused on single features/fixes
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
### Ruby
|
|
||||||
- Follow Rubocop Rails Omakase defaults
|
|
||||||
- Standard Rails MVC conventions
|
|
||||||
- Use descriptive method and variable names
|
|
||||||
- Prefer single quotes for strings without interpolation
|
|
||||||
|
|
||||||
### JavaScript/React
|
|
||||||
- Use Stimulus controllers for DOM interactions
|
|
||||||
- React components in PascalCase (`UserProfile.jsx`)
|
|
||||||
- Shadcn components in kebab-case (`button.jsx`) but exported as PascalCase
|
|
||||||
- Functional components with hooks over class components
|
|
||||||
|
|
||||||
### CSS/Tailwind
|
|
||||||
- Mobile-first responsive design
|
|
||||||
- Use Tailwind utility classes over custom CSS
|
|
||||||
- Primary color palette: indigo → purple → pink gradients
|
|
||||||
- Consistent spacing with Tailwind's spacing scale
|
|
||||||
|
|
||||||
### General
|
|
||||||
- Keep functions small and focused
|
|
||||||
- Comment complex logic
|
|
||||||
- Use descriptive commit messages
|
|
||||||
- Maintain consistency with existing code patterns
|
|
||||||
5
Gemfile
5
Gemfile
@@ -71,6 +71,11 @@ 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"
|
||||||
|
|||||||
16
Gemfile.lock
16
Gemfile.lock
@@ -184,6 +184,8 @@ GEM
|
|||||||
builder
|
builder
|
||||||
minitest (>= 5.0)
|
minitest (>= 5.0)
|
||||||
ruby-progressbar
|
ruby-progressbar
|
||||||
|
mocha (2.7.1)
|
||||||
|
ruby2_keywords (>= 0.0.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
mysql2 (0.5.6)
|
mysql2 (0.5.6)
|
||||||
net-imap (0.5.9)
|
net-imap (0.5.9)
|
||||||
@@ -209,6 +211,8 @@ 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)
|
||||||
@@ -265,6 +269,10 @@ GEM
|
|||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.2.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2.1)
|
railties (= 8.0.2.1)
|
||||||
|
rails-controller-testing (1.0.5)
|
||||||
|
actionpack (>= 5.0.1.rc1)
|
||||||
|
actionview (>= 5.0.1.rc1)
|
||||||
|
activesupport (>= 5.0.1.rc1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -325,6 +333,7 @@ GEM
|
|||||||
rubocop-performance (>= 1.24)
|
rubocop-performance (>= 1.24)
|
||||||
rubocop-rails (>= 2.30)
|
rubocop-rails (>= 2.30)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (3.0.2)
|
rubyzip (3.0.2)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.35.0)
|
selenium-webdriver (4.35.0)
|
||||||
@@ -353,6 +362,7 @@ 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)
|
||||||
@@ -369,7 +379,9 @@ 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)
|
||||||
@@ -405,6 +417,7 @@ 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
|
||||||
|
|
||||||
@@ -422,12 +435,14 @@ DEPENDENCIES
|
|||||||
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
|
||||||
@@ -438,6 +453,7 @@ 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
|
||||||
|
|||||||
28
QWEN.md
28
QWEN.md
@@ -1,28 +0,0 @@
|
|||||||
# Qwen Code Customization
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
- Working on a Ruby on Rails project named "aperonight"
|
|
||||||
- Using Docker for containerization
|
|
||||||
- Following Ruby version 3.1.0 (as indicated by .ruby-version)
|
|
||||||
- Using Bundler for gem management (Gemfile)
|
|
||||||
- Using Node.js for frontend assets (package.json likely present)
|
|
||||||
|
|
||||||
## Preferences
|
|
||||||
- Prefer to use Ruby and Rails conventions
|
|
||||||
- Follow Docker best practices for development environments
|
|
||||||
- Use standard Ruby/Rails project structure
|
|
||||||
- When creating new files, follow Rails conventions
|
|
||||||
- When modifying existing files, maintain consistency with current code style
|
|
||||||
- Use git for version control (as seen in .gitignore)
|
|
||||||
- Prefer to work with the project's existing toolchain (Bundler, etc.)
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
- When asked to make changes, first understand the context by examining relevant files
|
|
||||||
- When creating new files, ensure they follow project conventions
|
|
||||||
- When modifying files, preserve existing code style and patterns
|
|
||||||
- When implementing new features, suggest appropriate file locations and naming conventions
|
|
||||||
- When debugging, suggest using the project's existing test suite and development tools
|
|
||||||
- When suggesting changes, provide clear explanations of why the change is beneficial
|
|
||||||
|
|
||||||
## Qwen Added Memories
|
|
||||||
- We've implemented the checkout process with name collection for tickets that require identification. We've added first_name and last_name fields to the tickets table, updated the Ticket model with validations, added new routes and controller actions, created a view for collecting names, and updated the JavaScript controller. The database migration needs to be run in the Docker environment when the gem issues are resolved.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Checkout Process Implementation
|
|
||||||
|
|
||||||
This document describes the implementation of the checkout process with name collection for tickets that require identification.
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
The implementation includes:
|
|
||||||
|
|
||||||
1. Database migration to add first_name and last_name fields to tickets
|
|
||||||
2. Updates to the Ticket model to validate names when required
|
|
||||||
3. New routes and controller actions for name collection
|
|
||||||
4. A new view for collecting ticket holder names
|
|
||||||
5. Updates to the existing JavaScript controller
|
|
||||||
|
|
||||||
## Running the Migration
|
|
||||||
|
|
||||||
Once the Docker environment is fixed, run the following command to apply the database migration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec rails bundle exec rails db:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing the Implementation
|
|
||||||
|
|
||||||
1. Start the Docker containers:
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Visit an event page and select tickets that require identification
|
|
||||||
3. The checkout process should redirect to the name collection page
|
|
||||||
4. After submitting names, the user should be redirected to the payment page
|
|
||||||
5. After successful payment, tickets should be created with the provided names
|
|
||||||
|
|
||||||
## Code Structure
|
|
||||||
|
|
||||||
- Migration: `db/migrate/20250828143000_add_names_to_tickets.rb`
|
|
||||||
- Model: `app/models/ticket.rb`
|
|
||||||
- Controller: `app/controllers/events_controller.rb`
|
|
||||||
- Views:
|
|
||||||
- `app/views/events/collect_names.html.erb` (new)
|
|
||||||
- `app/views/events/show.html.erb` (updated)
|
|
||||||
- `app/views/components/_ticket_card.html.erb` (updated)
|
|
||||||
- Routes: `config/routes.rb` (updated)
|
|
||||||
- JavaScript: `app/javascript/controllers/ticket_cart_controller.js` (no changes needed)
|
|
||||||
185
app/assets/javascripts/qr_generator.js
Normal file
185
app/assets/javascripts/qr_generator.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// Self-contained QR Code Generator
|
||||||
|
// No external dependencies required
|
||||||
|
|
||||||
|
class QRCodeGenerator {
|
||||||
|
constructor() {
|
||||||
|
// QR Code error correction levels
|
||||||
|
this.errorCorrectionLevels = {
|
||||||
|
L: 1, // Low ~7%
|
||||||
|
M: 0, // Medium ~15%
|
||||||
|
Q: 3, // Quartile ~25%
|
||||||
|
H: 2 // High ~30%
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mode indicators
|
||||||
|
this.modes = {
|
||||||
|
NUMERIC: 1,
|
||||||
|
ALPHANUMERIC: 2,
|
||||||
|
BYTE: 4,
|
||||||
|
KANJI: 8
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code as SVG
|
||||||
|
generateSVG(text, options = {}) {
|
||||||
|
const size = options.size || 200;
|
||||||
|
const margin = options.margin || 4;
|
||||||
|
const errorCorrection = options.errorCorrection || 'M';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qrData = this.createQRData(text, errorCorrection);
|
||||||
|
const moduleSize = (size - 2 * margin) / qrData.length;
|
||||||
|
|
||||||
|
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`;
|
||||||
|
svg += `<rect width="${size}" height="${size}" fill="white"/>`;
|
||||||
|
|
||||||
|
for (let row = 0; row < qrData.length; row++) {
|
||||||
|
for (let col = 0; col < qrData[row].length; col++) {
|
||||||
|
if (qrData[row][col]) {
|
||||||
|
const x = margin + col * moduleSize;
|
||||||
|
const y = margin + row * moduleSize;
|
||||||
|
svg += `<rect x="${x}" y="${y}" width="${moduleSize}" height="${moduleSize}" fill="black"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg += '</svg>';
|
||||||
|
return svg;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('QR Code generation failed:', error);
|
||||||
|
return this.createErrorSVG(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create QR code data matrix (simplified implementation)
|
||||||
|
createQRData(text, errorCorrection) {
|
||||||
|
// For simplicity, we'll create a basic QR code pattern
|
||||||
|
// This is a minimal implementation - real QR codes are much more complex
|
||||||
|
|
||||||
|
const version = this.determineVersion(text.length);
|
||||||
|
const size = 21 + (version - 1) * 4; // QR code size formula
|
||||||
|
|
||||||
|
// Initialize matrix
|
||||||
|
const matrix = Array(size).fill().map(() => Array(size).fill(false));
|
||||||
|
|
||||||
|
// Add finder patterns (corners)
|
||||||
|
this.addFinderPatterns(matrix);
|
||||||
|
|
||||||
|
// Add timing patterns
|
||||||
|
this.addTimingPatterns(matrix);
|
||||||
|
|
||||||
|
// Add data (simplified - just create a pattern based on text)
|
||||||
|
this.addDataPattern(matrix, text);
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
determineVersion(length) {
|
||||||
|
// Simplified version determination
|
||||||
|
if (length <= 25) return 1;
|
||||||
|
if (length <= 47) return 2;
|
||||||
|
if (length <= 77) return 3;
|
||||||
|
return 4; // Max we'll support in this simple implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
addFinderPatterns(matrix) {
|
||||||
|
const size = matrix.length;
|
||||||
|
const pattern = [
|
||||||
|
[1,1,1,1,1,1,1],
|
||||||
|
[1,0,0,0,0,0,1],
|
||||||
|
[1,0,1,1,1,0,1],
|
||||||
|
[1,0,1,1,1,0,1],
|
||||||
|
[1,0,1,1,1,0,1],
|
||||||
|
[1,0,0,0,0,0,1],
|
||||||
|
[1,1,1,1,1,1,1]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Top-left
|
||||||
|
this.placePattern(matrix, 0, 0, pattern);
|
||||||
|
// Top-right
|
||||||
|
this.placePattern(matrix, 0, size - 7, pattern);
|
||||||
|
// Bottom-left
|
||||||
|
this.placePattern(matrix, size - 7, 0, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTimingPatterns(matrix) {
|
||||||
|
const size = matrix.length;
|
||||||
|
|
||||||
|
// Horizontal timing pattern
|
||||||
|
for (let i = 8; i < size - 8; i++) {
|
||||||
|
matrix[6][i] = i % 2 === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical timing pattern
|
||||||
|
for (let i = 8; i < size - 8; i++) {
|
||||||
|
matrix[i][6] = i % 2 === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addDataPattern(matrix, text) {
|
||||||
|
const size = matrix.length;
|
||||||
|
|
||||||
|
// Simple data pattern based on text hash
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + text.charCodeAt(i)) & 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill available spaces with pattern based on hash
|
||||||
|
for (let row = 0; row < size; row++) {
|
||||||
|
for (let col = 0; col < size; col++) {
|
||||||
|
if (!this.isReserved(row, col, size)) {
|
||||||
|
matrix[row][col] = ((hash >> ((row + col) % 32)) & 1) === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
placePattern(matrix, startRow, startCol, pattern) {
|
||||||
|
for (let row = 0; row < pattern.length; row++) {
|
||||||
|
for (let col = 0; col < pattern[row].length; col++) {
|
||||||
|
matrix[startRow + row][startCol + col] = pattern[row][col] === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isReserved(row, col, size) {
|
||||||
|
// Check if position is reserved for finder patterns, timing patterns, etc.
|
||||||
|
|
||||||
|
// Finder patterns
|
||||||
|
if ((row < 9 && col < 9) || // Top-left
|
||||||
|
(row < 9 && col >= size - 8) || // Top-right
|
||||||
|
(row >= size - 8 && col < 9)) { // Bottom-left
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing patterns
|
||||||
|
if (row === 6 || col === 6) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
createErrorSVG(size) {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="${size}" height="${size}" fill="#f3f4f6"/>
|
||||||
|
<text x="${size/2}" y="${size/2-10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">QR Code</text>
|
||||||
|
<text x="${size/2}" y="${size/2+10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Error</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global function for easy access
|
||||||
|
window.generateQRCode = function(text, containerId, options = {}) {
|
||||||
|
const generator = new QRCodeGenerator();
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
console.error('Container not found:', containerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = generator.generateSVG(text, options);
|
||||||
|
container.innerHTML = svg;
|
||||||
|
};
|
||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
/* Import pages */
|
/* Import pages */
|
||||||
@import "pages/home";
|
@import "pages/home";
|
||||||
|
@import "pages/payouts";
|
||||||
|
|||||||
816
app/assets/stylesheets/design_system.css
Normal file
816
app/assets/stylesheets/design_system.css
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
/**
|
||||||
|
* Aperonight Design System
|
||||||
|
* Generated from homepage analysis
|
||||||
|
* A modern, professional design system for event platforms
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* === ROOT VARIABLES === */
|
||||||
|
:root {
|
||||||
|
/* Brand Colors */
|
||||||
|
--brand-primary: #667eea;
|
||||||
|
--brand-secondary: #764ba2;
|
||||||
|
--brand-accent: #facc15; /* yellow-400 */
|
||||||
|
--brand-accent-dark: #eab308; /* yellow-500 */
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--color-black: #000000;
|
||||||
|
--color-gray-50: #f9fafb;
|
||||||
|
--color-gray-100: #f3f4f6;
|
||||||
|
--color-gray-200: #e5e7eb;
|
||||||
|
--color-gray-300: #d1d5db;
|
||||||
|
--color-gray-400: #9ca3af;
|
||||||
|
--color-gray-500: #6b7280;
|
||||||
|
--color-gray-600: #4b5563;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
--color-gray-800: #1f2937;
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
|
||||||
|
/* Purple Shades */
|
||||||
|
--color-purple-600: #9333ea;
|
||||||
|
--color-purple-700: #7c3aed;
|
||||||
|
--color-purple-800: #6b21a8;
|
||||||
|
|
||||||
|
/* Blue Shades */
|
||||||
|
--color-blue-600: #2563eb;
|
||||||
|
--color-blue-700: #1d4ed8;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||||
|
--font-family-mono: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 1.875rem; /* 30px */
|
||||||
|
--text-4xl: 2.25rem; /* 36px */
|
||||||
|
--text-5xl: 3rem; /* 48px */
|
||||||
|
--text-6xl: 3.75rem; /* 60px */
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
--font-bold: 700;
|
||||||
|
|
||||||
|
/* Spacing Scale */
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-4: 1rem; /* 16px */
|
||||||
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-8: 2rem; /* 32px */
|
||||||
|
--space-12: 3rem; /* 48px */
|
||||||
|
--space-16: 4rem; /* 64px */
|
||||||
|
--space-24: 6rem; /* 96px */
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 0.375rem; /* 6px */
|
||||||
|
--radius-md: 0.5rem; /* 8px */
|
||||||
|
--radius-lg: 0.75rem; /* 12px */
|
||||||
|
--radius-xl: 1rem; /* 16px */
|
||||||
|
--radius-2xl: 1.25rem; /* 20px */
|
||||||
|
--radius-3xl: 1.5rem; /* 24px */
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||||
|
|
||||||
|
/* Gradients */
|
||||||
|
--gradient-primary: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
|
||||||
|
--gradient-overlay: rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: all 0.2s ease;
|
||||||
|
--transition-medium: all 0.3s ease;
|
||||||
|
--transition-slow: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BASE STYLES === */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-sans) !important;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TYPOGRAPHY SYSTEM === */
|
||||||
|
.text-xs { font-size: var(--text-xs); }
|
||||||
|
.text-sm { font-size: var(--text-sm); }
|
||||||
|
.text-base { font-size: var(--text-base); }
|
||||||
|
.text-lg { font-size: var(--text-lg); }
|
||||||
|
.text-xl { font-size: var(--text-xl); }
|
||||||
|
.text-2xl { font-size: var(--text-2xl); }
|
||||||
|
.text-3xl { font-size: var(--text-3xl); }
|
||||||
|
.text-4xl { font-size: var(--text-4xl); }
|
||||||
|
.text-5xl { font-size: var(--text-5xl); }
|
||||||
|
.text-6xl { font-size: var(--text-6xl); }
|
||||||
|
|
||||||
|
.font-medium { font-weight: var(--font-medium); }
|
||||||
|
.font-semibold { font-weight: var(--font-semibold); }
|
||||||
|
.font-bold { font-weight: var(--font-bold); }
|
||||||
|
|
||||||
|
.leading-tight { line-height: 1.25; }
|
||||||
|
.leading-normal { line-height: 1.5; }
|
||||||
|
.leading-relaxed { line-height: 1.625; }
|
||||||
|
|
||||||
|
/* === BUTTON SYSTEM === */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-gray-100) !important;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--color-white) !important;
|
||||||
|
border: 2px solid var(--color-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-alt {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--color-gray-700) !important;
|
||||||
|
border: 2px solid var(--color-gray-300) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-alt:hover {
|
||||||
|
background-color: var(--color-gray-100) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
border-color: var(--color-gray-400) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background-color: var(--color-purple-600) !important;
|
||||||
|
color: var(--color-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover {
|
||||||
|
background-color: var(--color-purple-700) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
background-color: var(--color-gray-900) !important;
|
||||||
|
color: var(--color-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark:hover {
|
||||||
|
background-color: var(--color-gray-800) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Sizes */
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: var(--space-4) var(--space-8);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CARD SYSTEM === */
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
border-radius: var(--radius-2xl) !important;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image {
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event:hover .card-event-image img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-4);
|
||||||
|
left: var(--space-4);
|
||||||
|
background-color: var(--brand-accent) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-price {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-content {
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-title {
|
||||||
|
font-size: var(--text-2xl) !important;
|
||||||
|
font-weight: var(--font-bold) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event:hover .card-event-title {
|
||||||
|
color: var(--color-purple-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-meta {
|
||||||
|
color: var(--color-gray-600) !important;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-description {
|
||||||
|
color: var(--color-gray-500) !important;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
max-width: 20rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === HERO SYSTEM === */
|
||||||
|
.hero {
|
||||||
|
background: var(--gradient-primary) !important;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--gradient-overlay);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
color: var(--color-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: var(--text-4xl) !important;
|
||||||
|
font-weight: var(--font-bold) !important;
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: var(--text-xl) !important;
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-accent {
|
||||||
|
color: var(--brand-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Hero */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: var(--text-6xl) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === METRICS SYSTEM === */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-8);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-number {
|
||||||
|
font-size: var(--text-4xl) !important;
|
||||||
|
font-weight: var(--font-bold) !important;
|
||||||
|
color: var(--color-purple-600) !important;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.metric-number {
|
||||||
|
font-size: var(--text-5xl) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--color-gray-600) !important;
|
||||||
|
font-weight: var(--font-medium) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SECTION SYSTEM === */
|
||||||
|
.section {
|
||||||
|
padding: var(--space-16) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-3xl) !important;
|
||||||
|
font-weight: var(--font-bold) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-4xl) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: var(--text-xl) !important;
|
||||||
|
color: var(--color-gray-600) !important;
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === GRID SYSTEM === */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-1 { grid-template-columns: 1fr; }
|
||||||
|
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid-md-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid-md-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.grid-lg-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.grid-lg-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === UTILITY CLASSES === */
|
||||||
|
.container {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
padding-right: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
.bg-white { background-color: var(--color-white) !important; }
|
||||||
|
.bg-gray-50 { background-color: var(--color-gray-50) !important; }
|
||||||
|
.bg-gray-900 { background-color: var(--color-gray-900) !important; }
|
||||||
|
|
||||||
|
.text-white { color: var(--color-white) !important; }
|
||||||
|
.text-gray-600 { color: var(--color-gray-600) !important; }
|
||||||
|
.text-gray-900 { color: var(--color-gray-900) !important; }
|
||||||
|
|
||||||
|
.rounded-full { border-radius: var(--radius-full) !important; }
|
||||||
|
.rounded-2xl { border-radius: var(--radius-2xl) !important; }
|
||||||
|
|
||||||
|
.shadow-lg { box-shadow: var(--shadow-lg) !important; }
|
||||||
|
.shadow-xl { box-shadow: var(--shadow-xl) !important; }
|
||||||
|
|
||||||
|
.mb-2 { margin-bottom: var(--space-2) !important; }
|
||||||
|
.mb-4 { margin-bottom: var(--space-4) !important; }
|
||||||
|
.mb-6 { margin-bottom: var(--space-6) !important; }
|
||||||
|
.mb-8 { margin-bottom: var(--space-8) !important; }
|
||||||
|
.mb-12 { margin-bottom: var(--space-12) !important; }
|
||||||
|
|
||||||
|
.p-4 { padding: var(--space-4) !important; }
|
||||||
|
.p-6 { padding: var(--space-6) !important; }
|
||||||
|
.p-8 { padding: var(--space-8) !important; }
|
||||||
|
|
||||||
|
.flex { display: flex !important; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.gap-4 { gap: var(--space-4); }
|
||||||
|
|
||||||
|
.transition { transition: var(--transition-fast); }
|
||||||
|
|
||||||
|
.max-w-lg { max-width: 32rem; }
|
||||||
|
.max-w-2xl { max-width: 42rem; }
|
||||||
|
.max-w-4xl { max-width: 56rem; }
|
||||||
|
|
||||||
|
/* === BREADCRUMB SYSTEM === */
|
||||||
|
.breadcrumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item a {
|
||||||
|
color: var(--color-gray-700) !important;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item a:hover {
|
||||||
|
color: var(--color-purple-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
background: url("data:image/svg+xml,%3csvg fill='%234b5563' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' d='M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z' clip-rule='evenodd'/%3e%3c/svg%3e") center no-repeat;
|
||||||
|
background-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-current {
|
||||||
|
color: var(--color-purple-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === PAGE HEADER SYSTEM === */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: var(--space-8) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-3xl) !important;
|
||||||
|
font-weight: var(--font-bold) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-meta {
|
||||||
|
font-size: var(--text-sm) !important;
|
||||||
|
color: var(--color-gray-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === EVENTS GRID SYSTEM === */
|
||||||
|
.events-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.events-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.events-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
border-radius: var(--radius-xl) !important;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-image {
|
||||||
|
height: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: var(--transition-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover .event-card-image img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-placeholder {
|
||||||
|
height: 12rem;
|
||||||
|
background: var(--gradient-primary) !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-placeholder svg {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-content {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-title {
|
||||||
|
font-size: var(--text-xl) !important;
|
||||||
|
font-weight: var(--font-bold) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-venue {
|
||||||
|
font-size: var(--text-xs) !important;
|
||||||
|
color: var(--color-gray-500) !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-date {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2) calc(var(--space-2) + var(--space-1));
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs) !important;
|
||||||
|
font-weight: var(--font-medium) !important;
|
||||||
|
background-color: rgba(147, 51, 234, 0.1) !important;
|
||||||
|
color: var(--color-purple-800) !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-description {
|
||||||
|
color: var(--color-gray-600) !important;
|
||||||
|
font-size: var(--text-sm) !important;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-price {
|
||||||
|
font-size: var(--text-sm) !important;
|
||||||
|
font-weight: var(--font-medium) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-price-unavailable {
|
||||||
|
font-size: var(--text-sm) !important;
|
||||||
|
color: var(--color-gray-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-link {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: var(--text-sm) !important;
|
||||||
|
font-weight: var(--font-medium) !important;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
color: var(--color-white) !important;
|
||||||
|
background: var(--gradient-primary) !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-link:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === EMPTY STATE SYSTEM === */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-16) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 6rem;
|
||||||
|
height: 6rem;
|
||||||
|
margin: 0 auto var(--space-6);
|
||||||
|
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1) 0%, rgba(79, 70, 229, 0.1) 100%) !important;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon svg {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
color: var(--color-purple-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: var(--text-lg) !important;
|
||||||
|
font-weight: var(--font-medium) !important;
|
||||||
|
color: var(--color-gray-900) !important;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-description {
|
||||||
|
color: var(--color-gray-500) !important;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
max-width: 24rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === PAGINATION SYSTEM === */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item {
|
||||||
|
margin: 0 var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm) !important;
|
||||||
|
font-weight: var(--font-medium) !important;
|
||||||
|
color: var(--color-gray-600) !important;
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
border: 1px solid var(--color-gray-200) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-decoration: none !important;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link:hover {
|
||||||
|
background-color: var(--color-gray-50) !important;
|
||||||
|
border-color: var(--color-purple-300) !important;
|
||||||
|
color: var(--color-purple-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.active .page-link {
|
||||||
|
background-color: var(--color-purple-600) !important;
|
||||||
|
border-color: var(--color-purple-600) !important;
|
||||||
|
color: var(--color-white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.disabled .page-link {
|
||||||
|
color: var(--color-gray-300) !important;
|
||||||
|
background-color: var(--color-white) !important;
|
||||||
|
border-color: var(--color-gray-200) !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === RESPONSIVE UTILITIES === */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sm\:flex-col { flex-direction: column; }
|
||||||
|
.sm\:text-center { text-align: center; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-2xl) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sm\:flex-row { flex-direction: row; }
|
||||||
|
.sm\:flex-1 { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.lg\:justify-start { justify-content: flex-start; }
|
||||||
|
.lg\:text-left { text-align: left; }
|
||||||
|
}
|
||||||
304
app/assets/stylesheets/pages/payouts.css
Normal file
304
app/assets/stylesheets/pages/payouts.css
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/* Payouts specific styles */
|
||||||
|
|
||||||
|
.payout-status-progress {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-progress::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-icon.pending {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-icon.processing {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-icon.completed {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-icon.failed {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-icon.incomplete {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-step-date {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-summary-card {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.1), 0 2px 4px -1px rgba(16, 185, 129, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-summary-amount {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #047857;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-summary-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #059669;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-table-row:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-badge.pending {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-badge.processing {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-badge.completed {
|
||||||
|
background-color: #d1fae5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-status-badge.failed {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-empty-state-icon {
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-empty-state-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-empty-state-description {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-event-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-event-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-event-id {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.payout-detail-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-value.amount {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-detail-value.net-amount {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.primary {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.primary:hover {
|
||||||
|
background-color: #059669;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.secondary {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.secondary:hover {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.warning {
|
||||||
|
background-color: #fbbf24;
|
||||||
|
color: #713f12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.warning:hover {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.danger {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-action-button.danger:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
@@ -5,22 +5,33 @@
|
|||||||
--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: #8b5cf6;
|
--color-primary-500: #667eea;
|
||||||
--color-primary-600: #7c3aed;
|
--color-primary-600: #667eea;
|
||||||
--color-primary-700: #6d28d9;
|
--color-primary-700: #5a6fd8;
|
||||||
--color-primary-800: #5b21b6;
|
--color-primary-800: #4e63c6;
|
||||||
--color-primary-900: #4c1d95;
|
--color-primary-900: #4257b4;
|
||||||
|
|
||||||
--color-accent-50: #fdf2f8;
|
--color-accent-50: #fffbeb;
|
||||||
--color-accent-100: #fce7f3;
|
--color-accent-100: #fef3c7;
|
||||||
--color-accent-200: #fbcfe8;
|
--color-accent-200: #fde68a;
|
||||||
--color-accent-300: #f9a8d4;
|
--color-accent-300: #fcd34d;
|
||||||
--color-accent-400: #f472b6;
|
--color-accent-400: #facc15;
|
||||||
--color-accent-500: #ec4899;
|
--color-accent-500: #facc15;
|
||||||
--color-accent-600: #db2777;
|
--color-accent-600: #e6c213;
|
||||||
--color-accent-700: #be185d;
|
--color-accent-700: #d1b811;
|
||||||
--color-accent-800: #9d174d;
|
--color-accent-800: #bdae0f;
|
||||||
--color-accent-900: #831843;
|
--color-accent-900: #a8a40d;
|
||||||
|
|
||||||
|
--color-secondary-50: #f0e9f9;
|
||||||
|
--color-secondary-100: #e2d4f3;
|
||||||
|
--color-secondary-200: #c5a9e7;
|
||||||
|
--color-secondary-300: #a87edc;
|
||||||
|
--color-secondary-400: #8b53d0;
|
||||||
|
--color-secondary-500: #764ba2;
|
||||||
|
--color-secondary-600: #764ba2;
|
||||||
|
--color-secondary-700: #68428f;
|
||||||
|
--color-secondary-800: #5a397c;
|
||||||
|
--color-secondary-900: #4c3069;
|
||||||
|
|
||||||
--color-neutral-50: #fafafa;
|
--color-neutral-50: #fafafa;
|
||||||
--color-neutral-100: #f5f5f5;
|
--color-neutral-100: #f5f5f5;
|
||||||
@@ -87,9 +98,9 @@
|
|||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
--shadow-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(168, 85, 247, 0.1), 0 1px 2px 0 rgba(168, 85, 247, 0.06);
|
--shadow-purple-sm: 0 1px 3px 0 rgba(102, 126, 234, 0.1), 0 1px 2px 0 rgba(102, 126, 234, 0.06);
|
||||||
--shadow-purple-md: 0 4px 6px -1px rgba(168, 85, 247, 0.1), 0 2px 4px -1px 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-lg: 0 10px 15px -3px rgba(168, 85, 247, 0.1), 0 4px 6px -2px rgba(168, 85, 247, 0.05);
|
--shadow-purple-lg: 0 10px 15px -3px rgba(102, 126, 234, 0.1), 0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--duration-fast: 150ms;
|
--duration-fast: 150ms;
|
||||||
@@ -158,7 +169,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -179,12 +189,13 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
background: var(--color-primary-500);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -199,10 +210,44 @@ 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-600);
|
border: 2px solid var(--color-primary-500);
|
||||||
color: var(--color-primary-600);
|
color: var(--color-primary-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:hover {
|
.btn-outline:hover {
|
||||||
@@ -256,7 +301,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(168, 85, 247, 0.1);
|
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input::placeholder {
|
.form-input::placeholder {
|
||||||
@@ -295,7 +340,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(168, 85, 247, 0.1);
|
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badges */
|
/* Badges */
|
||||||
@@ -325,7 +370,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 solid var(--color-danger);
|
border: 1px border var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-featured {
|
.badge-featured {
|
||||||
@@ -508,7 +553,7 @@ p {
|
|||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(135deg, var(--color-primary-500) 0%, var(--color-accent-400) 100%);
|
background: var(--color-primary-500);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
transition: width var(--duration-slow) var(--ease-out);
|
transition: width var(--duration-slow) var(--ease-out);
|
||||||
}
|
}
|
||||||
@@ -689,59 +734,216 @@ p {
|
|||||||
|
|
||||||
/* Breadcrumbs */
|
/* Breadcrumbs */
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
font-size: var(--text-sm);
|
background: white;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-neutral-100);
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-item {
|
.breadcrumb-item {
|
||||||
color: var(--color-neutral-600);
|
display: inline-flex;
|
||||||
text-decoration: none;
|
align-items: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-item:hover {
|
.breadcrumb-link {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
color: var(--color-primary-600);
|
color: var(--color-primary-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-item.current {
|
.breadcrumb-current {
|
||||||
color: var(--color-neutral-900);
|
color: var(--color-primary-600);
|
||||||
font-weight: 600;
|
font-weight: var(--font-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-separator {
|
.breadcrumb-separator {
|
||||||
color: var(--color-neutral-400);
|
color: var(--color-neutral-400);
|
||||||
|
width: var(--space-4);
|
||||||
|
height: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Hero section */
|
||||||
@media (max-width: 768px) {
|
.hero {
|
||||||
.container {
|
background: linear-gradient(135deg, var(--color-primary-500) 0%, var(--color-secondary-500) 100%);
|
||||||
padding: 0 var(--space-3);
|
position: relative;
|
||||||
}
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
color: white;
|
||||||
|
padding: var(--space-16) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-accent {
|
||||||
|
color: var(--color-accent-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 36rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metrics grid */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin: var(--space-8) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-number {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
}
|
font-weight: 800;
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
.metric-label {
|
||||||
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);
|
font-size: var(--text-sm);
|
||||||
}
|
font-weight: 600;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
.form-input,
|
/* Cards */
|
||||||
.form-select,
|
.card {
|
||||||
.form-textarea {
|
background: white;
|
||||||
padding: var(--space-3);
|
border-radius: var(--radius-xl);
|
||||||
}
|
padding: var(--space-6);
|
||||||
|
border: 1px solid var(--color-neutral-200);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.hover-lift:hover {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
box-shadow: var(--shadow-2xl);
|
||||||
|
border-color: var(--color-primary-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
border: 1px solid var(--color-neutral-200);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event.hover-glow:hover {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
box-shadow: var(--shadow-2xl);
|
||||||
|
border-color: var(--color-primary-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-4);
|
||||||
|
left: var(--space-4);
|
||||||
|
background: var(--color-accent-400);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-price {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-content {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-meta {
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-description {
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Additional styles for enhanced Aperonight design */
|
/* Additional styles for enhanced Aperonight design */
|
||||||
@@ -788,3 +990,33 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/controllers/admin/payouts_controller.rb
Normal file
31
app/controllers/admin/payouts_controller.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class Admin::PayoutsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :ensure_admin!
|
||||||
|
|
||||||
|
def index
|
||||||
|
@payouts = Payout.includes(:event, :user)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.page(params[:page])
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@payout = Payout.find(params[:id])
|
||||||
|
|
||||||
|
begin
|
||||||
|
@payout.process_payout!
|
||||||
|
redirect_to admin_payouts_path, notice: "Payout processed successfully."
|
||||||
|
rescue => e
|
||||||
|
redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_admin!
|
||||||
|
# For now, we'll just check if the user has a stripe account
|
||||||
|
# In a real app, you'd have an admin role check
|
||||||
|
unless current_user.has_stripe_account?
|
||||||
|
redirect_to dashboard_path, alert: "Access denied."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
25
app/controllers/api/v1/carts_controller.rb
Normal file
25
app/controllers/api/v1/carts_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CartsController < ApiController
|
||||||
|
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||||
|
skip_before_action :authenticate_api_key, only: [ :store ]
|
||||||
|
|
||||||
|
def store
|
||||||
|
event_id = params[:event_id]
|
||||||
|
@event = Event.find(event_id)
|
||||||
|
|
||||||
|
cart_data = params[:cart] || {}
|
||||||
|
session[:pending_cart] = cart_data
|
||||||
|
session[:event_id] = @event.id
|
||||||
|
|
||||||
|
render json: { status: "success", message: "Cart stored successfully" }
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { status: "error", message: "Event not found" }, status: :not_found
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Unknown error"
|
||||||
|
Rails.logger.error "Error storing cart: #{error_message}"
|
||||||
|
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
# Contrôleur API pour la gestion des ressources d'événements
|
# API Controller for managing event resources
|
||||||
# Fournit des points de terminaison RESTful pour les opérations CRUD sur le modèle Event
|
# Provides RESTful endpoints for CRUD operations on the Event model
|
||||||
|
|
||||||
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)
|
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||||
skip_before_action :authenticate_api_key, only: [:store_cart]
|
skip_before_action :authenticate_api_key, only: [ :store_cart ]
|
||||||
|
|
||||||
# Charge l'évén avant certaines actions pour réduire les duplications
|
# Loads the event before certain actions to reduce duplications
|
||||||
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
|
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
|
||||||
|
|
||||||
# GET /api/v1/events
|
# GET /api/v1/events
|
||||||
# Récupère tous les événements triés par date de création (du plus récent au plus ancien)
|
# Retrieves all events sorted by creation date (most recent first)
|
||||||
def index
|
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
|
||||||
# Récupère un seul événement par son ID
|
# Retrieves a single event by its ID
|
||||||
# Retourne 404 si l'événement n'est pas trouvé
|
# Returns 404 if the event is not found
|
||||||
def show
|
def show
|
||||||
render json: @event, status: :ok
|
render json: @event, status: :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /api/v1/events
|
# POST /api/v1/events
|
||||||
# Crée un nouvel événement avec les attributs fournis
|
# Creates a new event with the provided attributes
|
||||||
# Retourne 201 Created en cas de succès avec les données de l'événement
|
# Returns 201 Created on success with the event data
|
||||||
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec
|
# Returns 422 Unprocessable Entity with error messages on failure
|
||||||
def create
|
def create
|
||||||
@event = Event.new(event_params)
|
@event = Event.new(event_params)
|
||||||
if @event.save
|
if @event.save
|
||||||
@@ -38,9 +38,9 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
# PATCH/PUT /api/v1/events/:id
|
# PATCH/PUT /api/v1/events/:id
|
||||||
# Met à jour un événement existant avec les attributs fournis
|
# Updates an existing event with the provided attributes
|
||||||
# Retourne 200 OK avec les données mises à jour en cas de succès
|
# Returns 200 OK with updated data on success
|
||||||
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec
|
# Returns 422 Unprocessable Entity with error messages on failure
|
||||||
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,8 +50,8 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
# DELETE /api/v1/events/:id
|
# DELETE /api/v1/events/:id
|
||||||
# Supprime définitivement un événement
|
# Permanently deletes an event
|
||||||
# Retourne 204 No Content en cas de succès
|
# Returns 204 No Content on success
|
||||||
def destroy
|
def destroy
|
||||||
@event.destroy
|
@event.destroy
|
||||||
head :no_content
|
head :no_content
|
||||||
@@ -66,33 +66,37 @@ module Api
|
|||||||
|
|
||||||
render json: { status: "success", message: "Cart stored successfully" }
|
render json: { status: "success", message: "Cart stored successfully" }
|
||||||
rescue => e
|
rescue => e
|
||||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
error_message = e.message.present? ? e.message : "Unknown error"
|
||||||
Rails.logger.error "Error storing cart: #{error_message}"
|
Rails.logger.error "Error storing cart: #{error_message}"
|
||||||
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Trouve un événement par son ID ou retourne 404 Introuvable
|
# Finds an event by its ID or returns 404 Not Found
|
||||||
# Utilisé comme before_action pour les actions show, update et destroy
|
# Used as before_action for the show, update, and destroy actions
|
||||||
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: "Événement non trouvé" }, status: :not_found
|
render json: { error: "Event not found" }, status: :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
# Paramètres forts pour la création et la mise à jour des événements
|
# Strong parameters for creating and updating events
|
||||||
# Liste blanche des attributs autorisés pour éviter les vulnérabilités de mass assignment
|
# Whitelist of allowed attributes to avoid mass assignment vulnerabilities
|
||||||
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
|
||||||
|
|||||||
281
app/controllers/api/v1/orders_controller.rb
Normal file
281
app/controllers/api/v1/orders_controller.rb
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# API controller for order management
|
||||||
|
# Provides RESTful endpoints for order operations
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class OrdersController < ApiController
|
||||||
|
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt ]
|
||||||
|
before_action :set_event, only: [ :new, :create ]
|
||||||
|
|
||||||
|
# Skip API key authentication for increment_payment_attempt action (used by frontend forms)
|
||||||
|
skip_before_action :authenticate_api_key, only: [ :increment_payment_attempt ]
|
||||||
|
|
||||||
|
# GET /api/v1/orders/new
|
||||||
|
# Returns data needed for new order form
|
||||||
|
def new
|
||||||
|
cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||||
|
|
||||||
|
if cart_data.empty?
|
||||||
|
render json: { error: "Veuillez d'abord sélectionner vos billets sur la page de l'événement" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
tickets_needing_names = []
|
||||||
|
cart_data.each do |ticket_type_id, item|
|
||||||
|
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
||||||
|
next unless ticket_type
|
||||||
|
|
||||||
|
quantity = item["quantity"].to_i
|
||||||
|
next if quantity <= 0
|
||||||
|
|
||||||
|
quantity.times do |i|
|
||||||
|
tickets_needing_names << {
|
||||||
|
ticket_type_id: ticket_type.id,
|
||||||
|
ticket_type_name: ticket_type.name,
|
||||||
|
ticket_type_price: ticket_type.price_cents,
|
||||||
|
index: i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { tickets_needing_names: tickets_needing_names }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /api/v1/orders
|
||||||
|
# Creates a new order with tickets
|
||||||
|
def create
|
||||||
|
cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||||
|
|
||||||
|
if cart_data.empty?
|
||||||
|
render json: { error: "Aucun billet sélectionné" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
success = false
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@order = current_user.orders.create!(event: @event, status: "draft")
|
||||||
|
|
||||||
|
order_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
||||||
|
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
||||||
|
|
||||||
|
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
||||||
|
|
||||||
|
ticket = @order.tickets.build(
|
||||||
|
ticket_type: ticket_type,
|
||||||
|
first_name: ticket_attrs[:first_name],
|
||||||
|
last_name: ticket_attrs[:last_name],
|
||||||
|
status: "draft"
|
||||||
|
)
|
||||||
|
|
||||||
|
unless ticket.save
|
||||||
|
render json: { error: "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" }, status: :unprocessable_entity
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if @order.tickets.present?
|
||||||
|
@order.calculate_total!
|
||||||
|
success = true
|
||||||
|
else
|
||||||
|
render json: { error: "Aucun billet valide créé" }, status: :unprocessable_entity
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if success
|
||||||
|
session[:draft_order_id] = @order.id
|
||||||
|
session.delete(:pending_cart)
|
||||||
|
render json: { order: @order, redirect_to: checkout_order_path(@order) }, status: :created
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||||
|
render json: { error: "Une erreur est survenue: #{error_message}" }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /api/v1/orders/:id
|
||||||
|
# Returns order summary
|
||||||
|
def show
|
||||||
|
tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
render json: { order: @order, tickets: tickets }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /api/v1/orders/:id/checkout
|
||||||
|
# Returns checkout data for an order
|
||||||
|
def checkout
|
||||||
|
if @order.expired?
|
||||||
|
@order.expire_if_overdue!
|
||||||
|
render json: { error: "Votre commande a expiré. Veuillez recommencer." }, status: :gone
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
total_amount = @order.total_amount_cents
|
||||||
|
expiring_soon = @order.expiring_soon?
|
||||||
|
|
||||||
|
checkout_session = nil
|
||||||
|
if Rails.application.config.stripe[:secret_key].present?
|
||||||
|
begin
|
||||||
|
checkout_session = create_stripe_session
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||||
|
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
|
||||||
|
render json: { error: "Erreur lors de la création de la session de paiement" }, status: :internal_server_error
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
order: @order,
|
||||||
|
tickets: tickets,
|
||||||
|
total_amount: total_amount,
|
||||||
|
expiring_soon: expiring_soon,
|
||||||
|
checkout_session: checkout_session
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH /api/v1/orders/:id/increment_payment_attempt
|
||||||
|
# Increments payment attempt counter
|
||||||
|
def increment_payment_attempt
|
||||||
|
@order.increment_payment_attempt!
|
||||||
|
render json: { success: true, attempts: @order.payment_attempts }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /api/v1/orders/:id/retry_payment
|
||||||
|
# Allows retrying payment for failed orders
|
||||||
|
def retry_payment
|
||||||
|
unless @order.can_retry_payment?
|
||||||
|
render json: { error: "Cette commande ne peut plus être payée" }, status: :forbidden
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { redirect_to: checkout_order_path(@order) }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /api/v1/orders/payment_success
|
||||||
|
# Handles successful payment confirmation
|
||||||
|
def payment_success
|
||||||
|
session_id = params[:session_id]
|
||||||
|
|
||||||
|
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
||||||
|
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
||||||
|
|
||||||
|
unless stripe_configured
|
||||||
|
render json: { error: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur." }, status: :service_unavailable
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
|
||||||
|
|
||||||
|
if stripe_session.payment_status == "paid"
|
||||||
|
order_id = stripe_session.metadata["order_id"]
|
||||||
|
|
||||||
|
unless order_id.present?
|
||||||
|
render json: { error: "Informations de commande manquantes" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
|
||||||
|
@order.mark_as_paid!
|
||||||
|
|
||||||
|
begin
|
||||||
|
StripeInvoiceGenerationJob.perform_later(@order.id)
|
||||||
|
Rails.logger.info "Scheduled Stripe invoice generation for order #{@order.id}"
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to schedule invoice generation for order #{@order.id}: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@order.tickets.each do |ticket|
|
||||||
|
begin
|
||||||
|
TicketMailer.purchase_confirmation(ticket).deliver_now
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to send confirmation email for ticket #{ticket.id}: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
session.delete(:pending_cart)
|
||||||
|
session.delete(:ticket_names)
|
||||||
|
session.delete(:draft_order_id)
|
||||||
|
|
||||||
|
render json: { order: @order, tickets: @order.tickets }, status: :ok
|
||||||
|
else
|
||||||
|
render json: { error: "Le paiement n'a pas été complété avec succès" }, status: :payment_required
|
||||||
|
end
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||||
|
render json: { error: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}" }, status: :bad_request
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||||
|
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
|
||||||
|
render json: { error: "Une erreur inattendue s'est produite : #{error_message}" }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /api/v1/orders/payment_cancel
|
||||||
|
# Handles payment cancellation
|
||||||
|
def payment_cancel
|
||||||
|
order_id = params[:order_id] || session[:draft_order_id]
|
||||||
|
|
||||||
|
if order_id.present?
|
||||||
|
order = current_user.orders.find_by(id: order_id, status: "draft")
|
||||||
|
|
||||||
|
if order&.can_retry_payment?
|
||||||
|
render json: { message: "Le paiement a été annulé. Vous pouvez réessayer.", redirect_to: checkout_order_path(order) }, status: :ok
|
||||||
|
else
|
||||||
|
session.delete(:draft_order_id)
|
||||||
|
render json: { message: "Le paiement a été annulé et votre commande a expiré." }, status: :gone
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render json: { message: "Le paiement a été annulé" }, status: :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_order
|
||||||
|
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "Commande non trouvée" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_event
|
||||||
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "Événement non trouvé" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def order_params
|
||||||
|
params.permit(tickets_attributes: [ :ticket_type_id, :first_name, :last_name ])
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_stripe_session
|
||||||
|
line_items = @order.tickets.map do |ticket|
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: "eur",
|
||||||
|
product_data: {
|
||||||
|
name: "#{@order.event.name} - #{ticket.ticket_type.name}",
|
||||||
|
description: ticket.ticket_type.description
|
||||||
|
},
|
||||||
|
unit_amount: ticket.price_cents
|
||||||
|
},
|
||||||
|
quantity: 1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Stripe::Checkout::Session.create(
|
||||||
|
payment_method_types: [ "card" ],
|
||||||
|
line_items: line_items,
|
||||||
|
mode: "payment",
|
||||||
|
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
cancel_url: order_payment_cancel_url,
|
||||||
|
metadata: {
|
||||||
|
order_id: @order.id,
|
||||||
|
user_id: current_user.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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 with: :null_session
|
protect_from_forgery prepend: true
|
||||||
|
|
||||||
# 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,8 +16,10 @@ class ApiController < ApplicationController
|
|||||||
# Extract API key from header or query parameter
|
# Extract API key from header or query parameter
|
||||||
api_key = request.headers["X-API-Key"] || params[:api_key]
|
api_key = request.headers["X-API-Key"] || params[:api_key]
|
||||||
|
|
||||||
# Validate against hardcoded key (in production, use environment variable)
|
# Validate against environment variable for security
|
||||||
unless api_key == "aperonight-api-key-2025"
|
expected_key = Rails.application.credentials.api_key || ENV["API_KEY"]
|
||||||
|
|
||||||
|
unless expected_key.present? && api_key == expected_key
|
||||||
render json: { error: "Unauthorized" }, status: :unauthorized
|
render json: { error: "Unauthorized" }, status: :unauthorized
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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
|
||||||
@@ -14,4 +17,27 @@ 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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Authentications::ConfirmationsController < Devise::ConfirmationsController
|
class Auth::ConfirmationsController < Devise::ConfirmationsController
|
||||||
# GET /resource/confirmation/new
|
# GET /resource/confirmation/new
|
||||||
# def new
|
# def new
|
||||||
# super
|
# super
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Authentications::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
# You should configure your model like this:
|
# You should configure your model like this:
|
||||||
# devise :omniauthable, omniauth_providers: [:twitter]
|
# devise :omniauthable, omniauth_providers: [:twitter]
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Authentications::PasswordsController < Devise::PasswordsController
|
class Auth::PasswordsController < Devise::PasswordsController
|
||||||
# GET /resource/password/new
|
# GET /resource/password/new
|
||||||
# def new
|
# def new
|
||||||
# super
|
# super
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Authentications::RegistrationsController < Devise::RegistrationsController
|
class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
before_action :configure_sign_up_params, only: [ :create ]
|
before_action :configure_sign_up_params, only: [ :create ]
|
||||||
before_action :configure_account_update_params, only: [ :update ]
|
before_action :configure_account_update_params, only: [ :update ]
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ class Authentications::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 ])
|
devise_parameter_sanitizer.permit(:account_update, keys: [ :last_name, :first_name, :is_professionnal ])
|
||||||
end
|
end
|
||||||
|
|
||||||
# The path used after sign up.
|
# The path used after sign up.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Authentications::SessionsController < Devise::SessionsController
|
class Auth::SessionsController < Devise::SessionsController
|
||||||
# before_action :configure_sign_in_params, only: [:create]
|
# before_action :configure_sign_in_params, only: [:create]
|
||||||
|
|
||||||
# GET /resource/sign_in
|
# GET /resource/sign_in
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Authentications::UnlocksController < Devise::UnlocksController
|
class Auth::UnlocksController < Devise::UnlocksController
|
||||||
# GET /resource/unlock/new
|
# GET /resource/unlock/new
|
||||||
# def new
|
# def new
|
||||||
# super
|
# super
|
||||||
@@ -1,31 +1,36 @@
|
|||||||
# Events controller
|
# Events controller - Public event listings and individual event display
|
||||||
#
|
#
|
||||||
# This controller manages all events. It load events for homepage
|
# This controller manages public event browsing and displays individual events
|
||||||
# and display for pagination.
|
# with their associated ticket types. No authentication required for public browsing.
|
||||||
class EventsController < ApplicationController
|
class EventsController < ApplicationController
|
||||||
|
# No authentication required for public event viewing
|
||||||
before_action :authenticate_user!, only: [ ]
|
before_action :authenticate_user!, only: []
|
||||||
before_action :set_event, only: [ :show ]
|
before_action :set_event, only: [ :show ]
|
||||||
|
|
||||||
# Display all events
|
# Display paginated list of upcoming published events
|
||||||
|
#
|
||||||
|
# Shows events in published state, ordered by start time ascending
|
||||||
|
# Includes event owner information and supports Kaminari pagination
|
||||||
def index
|
def index
|
||||||
@events = Event.includes(:user).upcoming.page(params[:page]).per(12)
|
@events = Event.includes(:user).upcoming.page(params[:page]).per(12)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Display desired event
|
# Display individual event with ticket type information
|
||||||
#
|
#
|
||||||
# Find requested event and display it to the user
|
# Shows complete event details including venue information,
|
||||||
|
# available ticket types, and allows users to add tickets to cart
|
||||||
def show
|
def show
|
||||||
# Event is set by set_event callback
|
# Event is set by set_event callback with ticket types preloaded
|
||||||
|
# Template will display event details and ticket selection interface
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Set the current event in the controller
|
# Find and set the current event with eager-loaded associations
|
||||||
#
|
#
|
||||||
# Expose the current @event property to method
|
# Loads event with ticket types to avoid N+1 queries
|
||||||
|
# Raises ActiveRecord::RecordNotFound if event doesn't exist
|
||||||
def set_event
|
def set_event
|
||||||
@event = Event.includes(:ticket_types).find(params[:id])
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
38
app/controllers/onboarding_controller.rb
Normal file
38
app/controllers/onboarding_controller.rb
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
class OnboardingController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :redirect_if_onboarding_complete, except: [ :complete ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
# Display the onboarding form
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete
|
||||||
|
if onboarding_params_valid?
|
||||||
|
current_user.update!(onboarding_params)
|
||||||
|
current_user.complete_onboarding!
|
||||||
|
|
||||||
|
flash[:notice] = "Bienvenue sur #{Rails.application.config.app_name} ! Votre profil a été configuré avec succès."
|
||||||
|
redirect_to dashboard_path
|
||||||
|
else
|
||||||
|
flash.now[:alert] = "Veuillez remplir tous les champs requis."
|
||||||
|
render :index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def onboarding_params
|
||||||
|
params.require(:user).permit(:first_name, :last_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def onboarding_params_valid?
|
||||||
|
onboarding_params[:first_name].present? &&
|
||||||
|
onboarding_params[:last_name].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_if_onboarding_complete
|
||||||
|
if current_user&.onboarding_completed?
|
||||||
|
redirect_to dashboard_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,15 +4,15 @@
|
|||||||
# Orders group multiple tickets together for better transaction management
|
# Orders group multiple tickets together for better transaction management
|
||||||
class OrdersController < ApplicationController
|
class OrdersController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :set_order, only: [:show, :checkout, :retry_payment, :increment_payment_attempt]
|
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt, :invoice ]
|
||||||
before_action :set_event, only: [:new, :create]
|
before_action :set_event, only: [ :new, :create ]
|
||||||
|
|
||||||
# Display new order form with name collection
|
# Display new order form with name collection
|
||||||
#
|
#
|
||||||
# On this page user can see order summary and complete the tickets details
|
# On this page user can see order summary and complete the tickets details
|
||||||
# (first name and last name) for each ticket ordered
|
# (first name and last name) for each ticket ordered
|
||||||
def new
|
def new
|
||||||
@cart_data = session[:pending_cart] || {}
|
@cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||||
|
|
||||||
if @cart_data.empty?
|
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"
|
redirect_to event_path(@event.slug, @event), alert: "Veuillez d'abord sélectionner vos billets sur la page de l'événement"
|
||||||
@@ -44,7 +44,7 @@ class OrdersController < ApplicationController
|
|||||||
# Here a new order is created with associated tickets in draft state.
|
# 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
|
# When user is ready they can proceed to payment via the order checkout
|
||||||
def create
|
def create
|
||||||
@cart_data = session[:pending_cart] || {}
|
@cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||||
|
|
||||||
if @cart_data.empty?
|
if @cart_data.empty?
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Aucun billet sélectionné"
|
redirect_to event_path(@event.slug, @event), alert: "Aucun billet sélectionné"
|
||||||
@@ -97,9 +97,15 @@ class OrdersController < ApplicationController
|
|||||||
redirect_to event_order_new_path(@event.slug, @event.id)
|
redirect_to event_order_new_path(@event.slug, @event.id)
|
||||||
end
|
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
|
# Display order summary
|
||||||
#
|
|
||||||
#
|
|
||||||
def show
|
def show
|
||||||
@tickets = @order.tickets.includes(:ticket_type)
|
@tickets = @order.tickets.includes(:ticket_type)
|
||||||
end
|
end
|
||||||
@@ -120,6 +126,15 @@ class OrdersController < ApplicationController
|
|||||||
@total_amount = @order.total_amount_cents
|
@total_amount = @order.total_amount_cents
|
||||||
@expiring_soon = @order.expiring_soon?
|
@expiring_soon = @order.expiring_soon?
|
||||||
|
|
||||||
|
# For free orders, automatically mark as paid and redirect to success
|
||||||
|
if @order.free?
|
||||||
|
@order.mark_as_paid!
|
||||||
|
session.delete(:pending_cart)
|
||||||
|
session.delete(:ticket_names)
|
||||||
|
session.delete(:draft_order_id)
|
||||||
|
return redirect_to order_path(@order), notice: "Vos billets gratuits ont été confirmés !"
|
||||||
|
end
|
||||||
|
|
||||||
# Create Stripe checkout session if Stripe is configured
|
# Create Stripe checkout session if Stripe is configured
|
||||||
if Rails.application.config.stripe[:secret_key].present?
|
if Rails.application.config.stripe[:secret_key].present?
|
||||||
begin
|
begin
|
||||||
@@ -146,7 +161,32 @@ class OrdersController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to order_checkout_path(@order)
|
# For POST requests, increment the payment attempt counter
|
||||||
|
if request.post?
|
||||||
|
@order.increment_payment_attempt!
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to checkout_order_path(@order)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display invoice for an order
|
||||||
|
def invoice
|
||||||
|
unless @order.status == "paid" || @order.status == "completed"
|
||||||
|
redirect_to order_path(@order), alert: "La facture n'est disponible qu'après le paiement de la commande"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
|
||||||
|
# Get the Stripe invoice if it exists
|
||||||
|
begin
|
||||||
|
@stripe_invoice_id = @order.create_stripe_invoice!
|
||||||
|
@stripe_invoice_pdf_url = @order.stripe_invoice_pdf_url if @stripe_invoice_id
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to retrieve or create Stripe invoice for order #{@order.id}: #{e.message}"
|
||||||
|
@stripe_invoice_id = nil
|
||||||
|
@stripe_invoice_pdf_url = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle successful payment
|
# Handle successful payment
|
||||||
@@ -158,7 +198,7 @@ class OrdersController < ApplicationController
|
|||||||
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
||||||
|
|
||||||
unless stripe_configured
|
unless stripe_configured
|
||||||
redirect_to dashboard_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
redirect_to root_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -178,16 +218,19 @@ class OrdersController < ApplicationController
|
|||||||
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
|
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
|
||||||
@order.mark_as_paid!
|
@order.mark_as_paid!
|
||||||
|
|
||||||
# Send confirmation emails
|
# Schedule Stripe invoice generation in background
|
||||||
@order.tickets.each do |ticket|
|
# This creates accounting records without blocking the payment success flow
|
||||||
begin
|
begin
|
||||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
StripeInvoiceGenerationJob.perform_later(@order.id)
|
||||||
|
Rails.logger.info "Scheduled Stripe invoice generation for order #{@order.id}"
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Failed to send confirmation email for ticket #{ticket.id}: #{e.message}"
|
Rails.logger.error "Failed to schedule invoice generation for order #{@order.id}: #{e.message}"
|
||||||
# Don't fail the entire payment process due to email/PDF generation issues
|
# Don't fail the payment process due to job scheduling issues
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Email confirmation is handled by the order model's mark_as_paid! method
|
||||||
|
# to avoid duplicate emails
|
||||||
|
|
||||||
# Clear session data
|
# Clear session data
|
||||||
session.delete(:pending_cart)
|
session.delete(:pending_cart)
|
||||||
session.delete(:ticket_names)
|
session.delete(:ticket_names)
|
||||||
@@ -209,20 +252,20 @@ class OrdersController < ApplicationController
|
|||||||
|
|
||||||
# Handle payment failure/cancellation
|
# Handle payment failure/cancellation
|
||||||
def payment_cancel
|
def payment_cancel
|
||||||
order_id = session[:draft_order_id]
|
order_id = params[:order_id] || session[:draft_order_id]
|
||||||
|
|
||||||
if order_id.present?
|
if order_id.present?
|
||||||
order = current_user.orders.find_by(id: order_id, status: "draft")
|
order = current_user.orders.find_by(id: order_id, status: "draft")
|
||||||
|
|
||||||
if order&.can_retry_payment?
|
if order&.can_retry_payment?
|
||||||
redirect_to order_checkout_path(order),
|
redirect_to checkout_order_path(order),
|
||||||
alert: "Le paiement a été annulé. Vous pouvez réessayer."
|
alert: "Le paiement a été annulé. Vous pouvez réessayer."
|
||||||
else
|
else
|
||||||
session.delete(:draft_order_id)
|
session.delete(:draft_order_id)
|
||||||
redirect_to dashboard_path, alert: "Le paiement a été annulé et votre commande a expiré."
|
redirect_to root_path, alert: "Le paiement a été annulé et votre commande a expiré."
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
redirect_to dashboard_path, alert: "Le paiement a été annulé"
|
redirect_to root_path, alert: "Le paiement a été annulé"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -231,7 +274,7 @@ class OrdersController < ApplicationController
|
|||||||
def set_order
|
def set_order
|
||||||
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
|
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to dashboard_path, alert: "Commande non trouvée"
|
redirect_to root_path, alert: "Commande non trouvée"
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_event
|
def set_event
|
||||||
@@ -259,8 +302,10 @@ class OrdersController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# No service fee added to customer; deducted from promoter payout
|
||||||
|
|
||||||
Stripe::Checkout::Session.create(
|
Stripe::Checkout::Session.create(
|
||||||
payment_method_types: ["card"],
|
payment_method_types: [ "card" ],
|
||||||
line_items: line_items,
|
line_items: line_items,
|
||||||
mode: "payment",
|
mode: "payment",
|
||||||
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
|||||||
@@ -3,44 +3,98 @@
|
|||||||
class PagesController < ApplicationController
|
class PagesController < ApplicationController
|
||||||
before_action :authenticate_user!, only: [ :dashboard ]
|
before_action :authenticate_user!, only: [ :dashboard ]
|
||||||
|
|
||||||
# Homepage showing featured events
|
# Homepage showing featured events as landing page
|
||||||
#
|
#
|
||||||
# Display homepage with featured events and incoming ones
|
# Display homepage with featured events and site metrics for all users
|
||||||
def home
|
def home
|
||||||
@featured_events = Event.published.featured.limit(3)
|
# Featured events for the main grid (6-9 events like Shotgun)
|
||||||
|
@featured_events = Event.published.featured.includes(:ticket_types).limit(9)
|
||||||
|
|
||||||
if user_signed_in?
|
# If no featured events, show latest published events
|
||||||
redirect_to(dashboard_path)
|
if @featured_events.empty?
|
||||||
|
@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
|
||||||
# Metrics for dashboard cards
|
# User's orders with associated data
|
||||||
@booked_events = current_user.orders.joins(tickets: { ticket_type: :event })
|
@user_orders = current_user.orders.includes(:event, tickets: :ticket_type)
|
||||||
.where(events: { state: :published })
|
.where(status: [ "paid", "completed" ])
|
||||||
.where(orders: { status: ['paid', 'completed'] })
|
.order(created_at: :desc)
|
||||||
.sum('1')
|
.limit(10)
|
||||||
@events_today = Event.published.where("DATE(start_time) = ?", Date.current).count
|
|
||||||
@events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count
|
|
||||||
@upcoming_events = Event.published.upcoming.count
|
|
||||||
|
|
||||||
# User's booked events
|
|
||||||
@user_booked_events = Event.joins(ticket_types: { tickets: :order })
|
|
||||||
.where(orders: { user: current_user }, tickets: { status: "active" })
|
|
||||||
.distinct
|
|
||||||
.limit(5)
|
|
||||||
|
|
||||||
# Draft orders that can be retried
|
# Draft orders that can be retried
|
||||||
@draft_orders = current_user.orders.includes(tickets: [:ticket_type, :event])
|
@draft_orders = current_user.orders.includes(tickets: [ :ticket_type, :event ])
|
||||||
.can_retry_payment
|
.can_retry_payment
|
||||||
.order(:expires_at)
|
.order(:expires_at)
|
||||||
|
|
||||||
# Events sections
|
# Promoter-specific data if user is a promoter
|
||||||
@today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc)
|
if current_user.promoter?
|
||||||
@tomorrow_events = Event.published.where("DATE(start_time) = ?", Date.current + 1).order(start_time: :asc)
|
@promoter_events = current_user.events.includes(:orders, :tickets)
|
||||||
@other_events = Event.published.upcoming.where.not("DATE(start_time) IN (?)", [ Date.current, Date.current + 1 ]).order(start_time: :asc).page(params[:page])
|
.order(created_at: :desc)
|
||||||
|
.limit(5)
|
||||||
|
|
||||||
|
# Revenue metrics for promoter
|
||||||
|
@total_revenue = current_user.events
|
||||||
|
.joins(:orders)
|
||||||
|
.where(orders: { status: [ "paid", "completed" ] })
|
||||||
|
.sum("orders.total_amount_cents") / 100.0
|
||||||
|
|
||||||
|
@total_tickets_sold = current_user.events
|
||||||
|
.joins(:tickets)
|
||||||
|
.where(tickets: { status: "active" })
|
||||||
|
.count
|
||||||
|
|
||||||
|
@active_events_count = current_user.events.where(state: "published").count
|
||||||
|
@draft_events_count = current_user.events.where(state: "draft").count
|
||||||
|
|
||||||
|
# Recent orders for promoter events
|
||||||
|
@recent_orders = Order.joins(:event)
|
||||||
|
.where(events: { user: current_user })
|
||||||
|
.where(status: [ "paid", "completed" ])
|
||||||
|
.includes(:event, :user, tickets: :ticket_type)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.limit(10)
|
||||||
|
|
||||||
|
# Monthly revenue trend (last 6 months)
|
||||||
|
@monthly_revenue = (0..5).map do |months_ago|
|
||||||
|
start_date = months_ago.months.ago.beginning_of_month
|
||||||
|
end_date = months_ago.months.ago.end_of_month
|
||||||
|
revenue = current_user.events
|
||||||
|
.joins(:orders)
|
||||||
|
.where(orders: { status: [ "paid", "completed" ] })
|
||||||
|
.where(orders: { created_at: start_date..end_date })
|
||||||
|
.sum("orders.total_amount_cents") / 100.0
|
||||||
|
|
||||||
|
{
|
||||||
|
month: start_date.strftime("%B %Y"),
|
||||||
|
revenue: revenue
|
||||||
|
}
|
||||||
|
end.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
# Simplified upcoming events preview - only show if user has orders
|
||||||
|
if @user_orders.any?
|
||||||
|
ordered_event_ids = @user_orders.map(&:event).map(&:id)
|
||||||
|
@upcoming_preview_events = Event.published
|
||||||
|
.upcoming
|
||||||
|
.where.not(id: ordered_event_ids)
|
||||||
|
.order(start_time: :asc)
|
||||||
|
.limit(6)
|
||||||
|
else
|
||||||
|
@upcoming_preview_events = []
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Events page showing all published events with pagination
|
# Events page showing all published events with pagination
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
class Promoter::EventsController < ApplicationController
|
class Promoter::EventsController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :ensure_can_manage_events!
|
before_action :ensure_can_manage_events!
|
||||||
before_action :set_event, only: [:show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out]
|
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :mark_available, :duplicate ]
|
||||||
|
|
||||||
# Display all events for the current promoter
|
# Display all events for the current promoter
|
||||||
def index
|
def index
|
||||||
@@ -27,7 +27,7 @@ class Promoter::EventsController < ApplicationController
|
|||||||
@event = current_user.events.build(event_params)
|
@event = current_user.events.build(event_params)
|
||||||
|
|
||||||
if @event.save
|
if @event.save
|
||||||
redirect_to promoter_event_path(@event), notice: 'Event créé avec succès!'
|
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
|
||||||
else
|
else
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -41,7 +41,7 @@ class Promoter::EventsController < ApplicationController
|
|||||||
# Update an existing event
|
# Update an existing event
|
||||||
def update
|
def update
|
||||||
if @event.update(event_params)
|
if @event.update(event_params)
|
||||||
redirect_to promoter_event_path(@event), notice: 'Event mis à jour avec succès!'
|
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
|
||||||
else
|
else
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -50,16 +50,16 @@ class Promoter::EventsController < ApplicationController
|
|||||||
# Delete an event
|
# Delete an event
|
||||||
def destroy
|
def destroy
|
||||||
@event.destroy
|
@event.destroy
|
||||||
redirect_to promoter_events_path, notice: 'Event supprimé avec succès!'
|
redirect_to promoter_events_path, notice: "Event supprimé avec succès!"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Publish an event (make it visible to public)
|
# Publish an event (make it visible to public)
|
||||||
def publish
|
def publish
|
||||||
if @event.draft?
|
if @event.draft?
|
||||||
@event.update(state: :published)
|
@event.update(state: :published)
|
||||||
redirect_to promoter_event_path(@event), notice: 'Event publié avec succès!'
|
redirect_to promoter_event_path(@event), notice: "Event publié avec succès!"
|
||||||
else
|
else
|
||||||
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être publié.'
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être publié."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ class Promoter::EventsController < ApplicationController
|
|||||||
def unpublish
|
def unpublish
|
||||||
if @event.published?
|
if @event.published?
|
||||||
@event.update(state: :draft)
|
@event.update(state: :draft)
|
||||||
redirect_to promoter_event_path(@event), notice: 'Event dépublié avec succès!'
|
redirect_to promoter_event_path(@event), notice: "Event dépublié avec succès!"
|
||||||
else
|
else
|
||||||
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être dépublié.'
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être dépublié."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -77,9 +77,9 @@ class Promoter::EventsController < ApplicationController
|
|||||||
def cancel
|
def cancel
|
||||||
if @event.published?
|
if @event.published?
|
||||||
@event.update(state: :canceled)
|
@event.update(state: :canceled)
|
||||||
redirect_to promoter_event_path(@event), notice: 'Event annulé avec succès!'
|
redirect_to promoter_event_path(@event), notice: "Event annulé avec succès!"
|
||||||
else
|
else
|
||||||
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être annulé.'
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être annulé."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -87,9 +87,31 @@ class Promoter::EventsController < ApplicationController
|
|||||||
def mark_sold_out
|
def mark_sold_out
|
||||||
if @event.published?
|
if @event.published?
|
||||||
@event.update(state: :sold_out)
|
@event.update(state: :sold_out)
|
||||||
redirect_to promoter_event_path(@event), notice: 'Event marqué comme complet!'
|
redirect_to promoter_event_path(@event), notice: "Event marqué comme complet!"
|
||||||
else
|
else
|
||||||
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être marqué comme complet.'
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme complet."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark event as available again
|
||||||
|
def mark_available
|
||||||
|
if @event.sold_out?
|
||||||
|
@event.update(state: :published)
|
||||||
|
redirect_to promoter_event_path(@event), notice: "Event marqué comme disponible!"
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme disponible."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Duplicate an event and all its ticket types
|
||||||
|
def duplicate
|
||||||
|
clone_ticket_types = params[:clone_ticket_types] == "true"
|
||||||
|
@new_event = @event.duplicate(clone_ticket_types: clone_ticket_types)
|
||||||
|
|
||||||
|
if @new_event
|
||||||
|
redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié."
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -97,21 +119,21 @@ class Promoter::EventsController < ApplicationController
|
|||||||
|
|
||||||
def ensure_can_manage_events!
|
def ensure_can_manage_events!
|
||||||
unless current_user.can_manage_events?
|
unless current_user.can_manage_events?
|
||||||
redirect_to dashboard_path, alert: 'Vous n\'avez pas les permissions nécessaires pour gérer des événements.'
|
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_event
|
def set_event
|
||||||
@event = current_user.events.find(params[:id])
|
@event = current_user.events.find(params[:id])
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to promoter_events_path, alert: 'Event non trouvé ou vous n\'avez pas accès à cet event.'
|
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
|
||||||
end
|
end
|
||||||
|
|
||||||
def event_params
|
def event_params
|
||||||
params.require(:event).permit(
|
params.require(:event).permit(
|
||||||
:name, :slug, :description, :image,
|
:name, :slug, :description, :image,
|
||||||
:venue_name, :venue_address, :latitude, :longitude,
|
:venue_name, :venue_address, :latitude, :longitude,
|
||||||
:start_time, :end_time, :featured
|
:start_time, :end_time, :featured, :allow_booking_during_event
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
66
app/controllers/promoter/payouts_controller.rb
Normal file
66
app/controllers/promoter/payouts_controller.rb
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
class Promoter::PayoutsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :ensure_promoter!
|
||||||
|
before_action :set_event, only: [:show, :create]
|
||||||
|
|
||||||
|
# List all payouts for the current promoter
|
||||||
|
def index
|
||||||
|
@payouts = current_user.payouts
|
||||||
|
.includes(:event)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.page(params[:page])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Show payout details
|
||||||
|
def show
|
||||||
|
@payout = @event.payouts.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new payout request
|
||||||
|
def create
|
||||||
|
# Check if event can request payout
|
||||||
|
unless @event.can_request_payout?
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Payout cannot be requested for this event."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Calculate payout amount
|
||||||
|
total_earnings_cents = @event.total_earnings_cents
|
||||||
|
total_fees_cents = @event.total_fees_cents
|
||||||
|
net_earnings_cents = @event.net_earnings_cents
|
||||||
|
|
||||||
|
# Count orders
|
||||||
|
total_orders_count = @event.orders.where(status: ['paid', 'completed']).count
|
||||||
|
refunded_orders_count = @event.tickets.where(status: 'refunded').joins(:order).where(orders: {status: ['paid', 'completed']}).count
|
||||||
|
|
||||||
|
# Create payout record
|
||||||
|
@payout = @event.payouts.build(
|
||||||
|
user: current_user,
|
||||||
|
amount_cents: total_earnings_cents,
|
||||||
|
fee_cents: total_fees_cents,
|
||||||
|
total_orders_count: total_orders_count,
|
||||||
|
refunded_orders_count: refunded_orders_count
|
||||||
|
)
|
||||||
|
|
||||||
|
if @payout.save
|
||||||
|
# Update event payout status
|
||||||
|
@event.update!(payout_status: :requested, payout_requested_at: Time.current)
|
||||||
|
|
||||||
|
redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully."
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_promoter!
|
||||||
|
unless current_user.promoter?
|
||||||
|
redirect_to dashboard_path, alert: "Access denied."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_event
|
||||||
|
@event = current_user.events.find(params[:event_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,7 +6,7 @@ class Promoter::TicketTypesController < ApplicationController
|
|||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :ensure_can_manage_events!
|
before_action :ensure_can_manage_events!
|
||||||
before_action :set_event
|
before_action :set_event
|
||||||
before_action :set_ticket_type, only: [:show, :edit, :update, :destroy]
|
before_action :set_ticket_type, only: [ :show, :edit, :update, :destroy ]
|
||||||
|
|
||||||
# Display all ticket types for an event
|
# Display all ticket types for an event
|
||||||
def index
|
def index
|
||||||
@@ -32,7 +32,7 @@ class Promoter::TicketTypesController < ApplicationController
|
|||||||
@ticket_type = @event.ticket_types.build(ticket_type_params)
|
@ticket_type = @event.ticket_types.build(ticket_type_params)
|
||||||
|
|
||||||
if @ticket_type.save
|
if @ticket_type.save
|
||||||
redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet créé avec succès!'
|
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet créé avec succès!"
|
||||||
else
|
else
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -46,7 +46,7 @@ class Promoter::TicketTypesController < ApplicationController
|
|||||||
# Update an existing ticket type
|
# Update an existing ticket type
|
||||||
def update
|
def update
|
||||||
if @ticket_type.update(ticket_type_params)
|
if @ticket_type.update(ticket_type_params)
|
||||||
redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet mis à jour avec succès!'
|
redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet mis à jour avec succès!"
|
||||||
else
|
else
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -55,10 +55,10 @@ class Promoter::TicketTypesController < ApplicationController
|
|||||||
# Delete a ticket type
|
# Delete a ticket type
|
||||||
def destroy
|
def destroy
|
||||||
if @ticket_type.tickets.any?
|
if @ticket_type.tickets.any?
|
||||||
redirect_to promoter_event_ticket_types_path(@event), alert: 'Impossible de supprimer ce type de billet car des billets ont déjà été vendus.'
|
redirect_to promoter_event_ticket_types_path(@event), alert: "Impossible de supprimer ce type de billet car des billets ont déjà été vendus."
|
||||||
else
|
else
|
||||||
@ticket_type.destroy
|
@ticket_type.destroy
|
||||||
redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet supprimé avec succès!'
|
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet supprimé avec succès!"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ class Promoter::TicketTypesController < ApplicationController
|
|||||||
@ticket_type.name = "#{original.name} (Copie)"
|
@ticket_type.name = "#{original.name} (Copie)"
|
||||||
|
|
||||||
if @ticket_type.save
|
if @ticket_type.save
|
||||||
redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet dupliqué avec succès!'
|
redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet dupliqué avec succès!"
|
||||||
else
|
else
|
||||||
redirect_to promoter_event_ticket_types_path(@event), alert: 'Erreur lors de la duplication.'
|
redirect_to promoter_event_ticket_types_path(@event), alert: "Erreur lors de la duplication."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -79,20 +79,20 @@ class Promoter::TicketTypesController < ApplicationController
|
|||||||
|
|
||||||
def ensure_can_manage_events!
|
def ensure_can_manage_events!
|
||||||
unless current_user.can_manage_events?
|
unless current_user.can_manage_events?
|
||||||
redirect_to dashboard_path, alert: 'Vous n\'avez pas les permissions nécessaires pour gérer des événements.'
|
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_event
|
def set_event
|
||||||
@event = current_user.events.find(params[:event_id])
|
@event = current_user.events.find(params[:event_id])
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to promoter_events_path, alert: 'Event non trouvé ou vous n\'avez pas accès à cet event.'
|
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_ticket_type
|
def set_ticket_type
|
||||||
@ticket_type = @event.ticket_types.find(params[:id])
|
@ticket_type = @event.ticket_types.find(params[:id])
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to promoter_event_ticket_types_path(@event), alert: 'Type de billet non trouvé.'
|
redirect_to promoter_event_ticket_types_path(@event), alert: "Type de billet non trouvé."
|
||||||
end
|
end
|
||||||
|
|
||||||
def ticket_type_params
|
def ticket_type_params
|
||||||
|
|||||||
26
app/controllers/settings_controller.rb
Normal file
26
app/controllers/settings_controller.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class SettingsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_user
|
||||||
|
|
||||||
|
def show
|
||||||
|
# Show settings page
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @user.update(user_params)
|
||||||
|
redirect_to settings_path, notice: "Vos informations ont été mises à jour avec succès."
|
||||||
|
else
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_user
|
||||||
|
@user = current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_params
|
||||||
|
params.require(:user).permit(:first_name, :last_name, :is_professionnal)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# This controller now primarily handles legacy redirects and backward compatibility
|
# This controller now primarily handles legacy redirects and backward compatibility
|
||||||
# Most ticket creation functionality has been moved to OrdersController
|
# Most ticket creation functionality has been moved to OrdersController
|
||||||
class TicketsController < ApplicationController
|
class TicketsController < ApplicationController
|
||||||
before_action :authenticate_user!, only: [ :payment_success, :payment_cancel ]
|
before_action :authenticate_user!, only: [ :payment_success, :payment_cancel, :show, :download ]
|
||||||
before_action :set_event, only: [ :checkout, :retry_payment ]
|
before_action :set_event, only: [ :checkout, :retry_payment ]
|
||||||
|
|
||||||
|
|
||||||
@@ -48,12 +48,51 @@ class TicketsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Display ticket details
|
||||||
def show
|
def show
|
||||||
@ticket = current_user.orders.joins(:tickets).find(params[:ticket_id])
|
# Find ticket by qr code id
|
||||||
|
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
|
||||||
|
.find_by(tickets: { qr_code: params[:qr_code] })
|
||||||
|
|
||||||
|
if @ticket.nil?
|
||||||
|
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
@event = @ticket.event
|
@event = @ticket.event
|
||||||
|
@order = @ticket.order
|
||||||
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to dashboard_path, alert: "Billet non trouvé"
|
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def set_event
|
def set_event
|
||||||
@@ -73,34 +112,4 @@ class TicketsController < ApplicationController
|
|||||||
Rails.logger.error "TicketsController#set_event - Event not found with ID: #{event_id}"
|
Rails.logger.error "TicketsController#set_event - Event not found with ID: #{event_id}"
|
||||||
redirect_to events_path, alert: "Événement non trouvé"
|
redirect_to events_path, alert: "Événement non trouvé"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
def create_stripe_session
|
|
||||||
line_items = @tickets.map do |ticket|
|
|
||||||
{
|
|
||||||
price_data: {
|
|
||||||
currency: "eur",
|
|
||||||
product_data: {
|
|
||||||
name: "#{@event.name} - #{ticket.ticket_type.name}",
|
|
||||||
description: ticket.ticket_type.description
|
|
||||||
},
|
|
||||||
unit_amount: ticket.price_cents
|
|
||||||
},
|
|
||||||
quantity: 1
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
Stripe::Checkout::Session.create(
|
|
||||||
payment_method_types: [ "card" ],
|
|
||||||
line_items: line_items,
|
|
||||||
mode: "payment",
|
|
||||||
success_url: payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
|
||||||
cancel_url: payment_cancel_url,
|
|
||||||
metadata: {
|
|
||||||
event_id: @event.id,
|
|
||||||
user_id: current_user.id,
|
|
||||||
ticket_ids: @tickets.pluck(:id).join(",")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
2
app/helpers/admin/payouts_helper.rb
Normal file
2
app/helpers/admin/payouts_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module Admin::PayoutsHelper
|
||||||
|
end
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
# Convert prince from cents to float
|
# Convert price from cents to float
|
||||||
def format_price(cents)
|
def format_price(cents)
|
||||||
(cents.to_f / 100).round(2)
|
(cents.to_f / 100).round(2)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
|
# Flash messages helper for consistent styling across the application
|
||||||
|
#
|
||||||
|
# Provides standardized CSS classes and icons for different types of flash messages
|
||||||
|
# using Tailwind CSS classes and Lucide icons for consistent UI presentation
|
||||||
module FlashMessagesHelper
|
module FlashMessagesHelper
|
||||||
|
# Return appropriate Tailwind CSS classes for different flash message types
|
||||||
|
#
|
||||||
|
# @param type [String, Symbol] The flash message type (notice, error, warning, info)
|
||||||
|
# @return [String] Tailwind CSS classes for styling the flash message container
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# flash_class('success') # => "bg-green-50 text-green-800 border-green-200"
|
||||||
|
# flash_class('error') # => "bg-red-50 text-red-800 border-red-200"
|
||||||
def flash_class(type)
|
def flash_class(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when "notice", "success"
|
when "notice", "success"
|
||||||
@@ -14,6 +26,14 @@ module FlashMessagesHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return appropriate Lucide icon for different flash message types
|
||||||
|
#
|
||||||
|
# @param type [String, Symbol] The flash message type
|
||||||
|
# @return [String] HTML content tag with Lucide icon data attribute
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# flash_icon('success') # => <i data-lucide="check-circle" class="..."></i>
|
||||||
|
# flash_icon('error') # => <i data-lucide="x-circle" class="..."></i>
|
||||||
def flash_icon(type)
|
def flash_icon(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when "notice", "success"
|
when "notice", "success"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ module LucideHelper
|
|||||||
# lucide_icon('check-circle', class: 'text-green-500', size: 'w-5 h-5')
|
# lucide_icon('check-circle', class: 'text-green-500', size: 'w-5 h-5')
|
||||||
# lucide_icon('menu', data: { action: 'click->header#toggleMenu' })
|
# lucide_icon('menu', data: { action: 'click->header#toggleMenu' })
|
||||||
def lucide_icon(name, options = {})
|
def lucide_icon(name, options = {})
|
||||||
css_classes = ["lucide-icon"]
|
css_classes = [ "lucide-icon" ]
|
||||||
css_classes << options[:size] if options[:size]
|
css_classes << options[:size] if options[:size]
|
||||||
css_classes << options[:class] if options[:class]
|
css_classes << options[:class] if options[:class]
|
||||||
|
|
||||||
@@ -42,12 +42,12 @@ module LucideHelper
|
|||||||
def lucide_button(name, options = {})
|
def lucide_button(name, options = {})
|
||||||
text = options.delete(:text)
|
text = options.delete(:text)
|
||||||
icon_class = options.delete(:icon_class)
|
icon_class = options.delete(:icon_class)
|
||||||
icon_size = options.delete(:icon_size) || 'w-4 h-4'
|
icon_size = options.delete(:icon_size) || "w-4 h-4"
|
||||||
|
|
||||||
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
||||||
|
|
||||||
content = if text.present?
|
content = if text.present?
|
||||||
safe_join([icon, " ", text])
|
safe_join([ icon, " ", text ])
|
||||||
else
|
else
|
||||||
icon
|
icon
|
||||||
end
|
end
|
||||||
@@ -67,12 +67,12 @@ module LucideHelper
|
|||||||
def lucide_link(name, url, options = {})
|
def lucide_link(name, url, options = {})
|
||||||
text = options.delete(:text)
|
text = options.delete(:text)
|
||||||
icon_class = options.delete(:icon_class)
|
icon_class = options.delete(:icon_class)
|
||||||
icon_size = options.delete(:icon_size) || 'w-4 h-4'
|
icon_size = options.delete(:icon_size) || "w-4 h-4"
|
||||||
|
|
||||||
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
||||||
|
|
||||||
content = if text.present?
|
content = if text.present?
|
||||||
safe_join([icon, " ", text])
|
safe_join([ icon, " ", text ])
|
||||||
else
|
else
|
||||||
icon
|
icon
|
||||||
end
|
end
|
||||||
|
|||||||
2
app/helpers/onboarding_helper.rb
Normal file
2
app/helpers/onboarding_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module OnboardingHelper
|
||||||
|
end
|
||||||
2
app/helpers/promoter/payouts_helper.rb
Normal file
2
app/helpers/promoter/payouts_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module Promoter::PayoutsHelper
|
||||||
|
end
|
||||||
@@ -1,2 +1,9 @@
|
|||||||
module TicketsHelper
|
module TicketsHelper
|
||||||
|
def format_ticket_price(price_cents)
|
||||||
|
if price_cents == 0
|
||||||
|
"Gratuit"
|
||||||
|
else
|
||||||
|
number_to_currency(price_cents / 100.0, unit: "€")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,3 +6,18 @@ import "@hotwired/turbo-rails";
|
|||||||
|
|
||||||
// Import all Stimulus controllers
|
// Import all Stimulus controllers
|
||||||
import "./controllers";
|
import "./controllers";
|
||||||
|
|
||||||
|
// Import and initialize Lucide icons globally
|
||||||
|
import { createIcons, icons } from 'lucide';
|
||||||
|
|
||||||
|
// Initialize icons globally
|
||||||
|
function initializeLucideIcons() {
|
||||||
|
createIcons({ icons });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on initial page load
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeLucideIcons);
|
||||||
|
|
||||||
|
// Run on Turbo navigation (Rails 7+ SPA behavior)
|
||||||
|
document.addEventListener('turbo:render', initializeLucideIcons);
|
||||||
|
document.addEventListener('turbo:frame-render', initializeLucideIcons);
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva } from "class-variance-authority";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// Define button styles using class-variance-authority for consistent styling
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"bg-purple text-purple-foreground shadow-xs hover:bg-purple/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
||||||
outline:
|
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-purple underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
||||||
icon: "size-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Button component that can render as a regular button or as a Slot (for composition)
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}) {
|
|
||||||
// Use Slot component if asChild is true, otherwise render as a regular button
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
71
app/javascript/controllers/countdown_controller.js
Normal file
71
app/javascript/controllers/countdown_controller.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Countdown controller for displaying remaining time until order expiration
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
expiresAt: String, // ISO timestamp when the order expires
|
||||||
|
orderId: Number // Order ID for identification
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Parse the expiration timestamp
|
||||||
|
this.expirationTime = new Date(this.expiresAtValue).getTime()
|
||||||
|
|
||||||
|
// Find the countdown element
|
||||||
|
this.countdownElement = this.element.querySelector('.countdown-timer')
|
||||||
|
|
||||||
|
if (this.countdownElement && !isNaN(this.expirationTime)) {
|
||||||
|
// Start the countdown
|
||||||
|
this.updateCountdown()
|
||||||
|
this.timer = setInterval(() => this.updateCountdown(), 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
// Clean up the interval when the controller disconnects
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCountdown() {
|
||||||
|
const now = new Date().getTime()
|
||||||
|
const distance = this.expirationTime - now
|
||||||
|
|
||||||
|
// If the countdown is finished
|
||||||
|
if (distance < 0) {
|
||||||
|
this.countdownElement.innerHTML = "EXPIRÉ"
|
||||||
|
this.countdownElement.classList.add("text-red-600", "font-bold")
|
||||||
|
this.countdownElement.classList.remove("text-orange-600")
|
||||||
|
|
||||||
|
// Add a more urgent visual indicator
|
||||||
|
this.element.classList.add("bg-red-50", "border-red-200")
|
||||||
|
this.element.classList.remove("bg-orange-50", "border-orange-200")
|
||||||
|
|
||||||
|
// Stop the timer
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time components
|
||||||
|
const seconds = Math.floor(distance / 1000)
|
||||||
|
|
||||||
|
// Display the result
|
||||||
|
this.countdownElement.innerHTML = `${seconds} secondes`
|
||||||
|
|
||||||
|
// Add urgency styling when time is running low
|
||||||
|
if (seconds < 60) {
|
||||||
|
this.countdownElement.classList.add("text-red-600", "font-bold")
|
||||||
|
this.countdownElement.classList.remove("text-orange-600")
|
||||||
|
|
||||||
|
// Add background warning for extra urgency
|
||||||
|
this.element.classList.add("bg-red-50", "border-red-200")
|
||||||
|
this.element.classList.remove("bg-orange-50", "border-orange-200")
|
||||||
|
} else if (seconds < 300) { // Less than 5 minutes
|
||||||
|
this.countdownElement.classList.add("text-orange-600", "font-bold")
|
||||||
|
this.element.classList.add("bg-orange-50", "border-orange-200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/javascript/controllers/event_duplication_controller.js
Normal file
53
app/javascript/controllers/event_duplication_controller.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["cloneTicketTypes"]
|
||||||
|
static values = {
|
||||||
|
duplicateUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Get modal element from the document
|
||||||
|
this.modalElement = document.querySelector('[data-event-duplication-target="modal"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.modalElement.classList.remove('hidden')
|
||||||
|
document.body.classList.add('overflow-hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.modalElement.classList.add('hidden')
|
||||||
|
document.body.classList.remove('overflow-hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate() {
|
||||||
|
const cloneTicketTypes = this.cloneTicketTypesTarget.checked
|
||||||
|
|
||||||
|
// Create form data
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('clone_ticket_types', cloneTicketTypes)
|
||||||
|
formData.append('authenticity_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'))
|
||||||
|
|
||||||
|
// Send request to duplicate endpoint
|
||||||
|
fetch(this.duplicateUrlValue, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.redirected) {
|
||||||
|
window.location.href = response.url
|
||||||
|
} else {
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error)
|
||||||
|
alert('Erreur lors de la duplication de l\'événement.')
|
||||||
|
this.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,667 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import slug from 'slug'
|
||||||
|
|
||||||
// Event form controller for handling form interactions
|
|
||||||
// Handles auto-slug generation from event names
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["name", "slug"]
|
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
|
||||||
|
static values = {
|
||||||
|
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
||||||
|
}
|
||||||
|
|
||||||
|
static lastGeocodingRequest = 0
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
console.log("Event form controller connected")
|
this.geocodeTimeout = null
|
||||||
|
this.isManualGeocodingInProgress = false
|
||||||
|
|
||||||
|
// Initialize map links if we have an address and coordinates already exist
|
||||||
|
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
|
||||||
|
this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||||
|
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||||
|
this.updateMapLinks()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-generate slug from name input
|
disconnect() {
|
||||||
|
if (this.geocodeTimeout) {
|
||||||
|
clearTimeout(this.geocodeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug from name
|
||||||
generateSlug() {
|
generateSlug() {
|
||||||
// Only auto-generate if slug field is empty
|
const name = this.nameTarget.value
|
||||||
if (this.slugTarget.value === "") {
|
|
||||||
const slug = this.nameTarget.value
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "") // Remove accents
|
|
||||||
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars
|
|
||||||
.replace(/\s+/g, "-") // Replace spaces with dashes
|
|
||||||
.replace(/-+/g, "-") // Remove duplicate dashes
|
|
||||||
.replace(/^-|-$/g, "") // Remove leading/trailing dashes
|
|
||||||
|
|
||||||
this.slugTarget.value = slug
|
this.slugTarget.value = slug(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle address changes with debounced geocoding
|
||||||
|
addressChanged() {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (this.geocodeTimeout) {
|
||||||
|
clearTimeout(this.geocodeTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.addressTarget.value.trim()
|
||||||
|
|
||||||
|
if (!address) {
|
||||||
|
this.clearCoordinates()
|
||||||
|
this.clearMapLinks()
|
||||||
|
this.hideGeocodingSpinner()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show spinner after a brief delay to avoid flickering for very short typing
|
||||||
|
const showSpinnerTimeout = setTimeout(() => {
|
||||||
|
this.showGeocodingSpinner()
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
// Debounce geocoding to avoid too many API calls
|
||||||
|
this.geocodeTimeout = setTimeout(async () => {
|
||||||
|
clearTimeout(showSpinnerTimeout) // Cancel spinner delay if still pending
|
||||||
|
this.showGeocodingSpinner() // Show spinner for sure now
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.geocodeAddressQuiet(address)
|
||||||
|
} finally {
|
||||||
|
this.hideGeocodingSpinner()
|
||||||
|
}
|
||||||
|
}, this.geocodeDelayValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's current location and reverse geocode to address
|
||||||
|
async getCurrentLocation() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
this.showLocationError("La géolocalisation n'est pas supportée par ce navigateur.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showGetCurrentLocationLoading()
|
||||||
|
this.showLocationLoading()
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 60000
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const position = await this.getCurrentPositionPromise(options)
|
||||||
|
const lat = position.coords.latitude
|
||||||
|
const lng = position.coords.longitude
|
||||||
|
|
||||||
|
// Set coordinates first
|
||||||
|
this.latitudeTarget.value = lat.toFixed(6)
|
||||||
|
this.longitudeTarget.value = lng.toFixed(6)
|
||||||
|
|
||||||
|
// Then reverse geocode to get address
|
||||||
|
const address = await this.reverseGeocode(lat, lng)
|
||||||
|
|
||||||
|
if (address) {
|
||||||
|
this.addressTarget.value = address
|
||||||
|
this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
|
||||||
|
} else {
|
||||||
|
this.showLocationSuccess("Position actuelle détectée!")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateMapLinks()
|
||||||
|
this.hideGetCurrentLocationLoading()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.hideGetCurrentLocationLoading()
|
||||||
|
this.hideLocationLoading()
|
||||||
|
let message = "Erreur lors de la récupération de la localisation."
|
||||||
|
|
||||||
|
switch(error.code) {
|
||||||
|
case error.PERMISSION_DENIED:
|
||||||
|
message = "L'accès à la localisation a été refusé."
|
||||||
|
break
|
||||||
|
case error.POSITION_UNAVAILABLE:
|
||||||
|
message = "Les informations de localisation ne sont pas disponibles."
|
||||||
|
break
|
||||||
|
case error.TIMEOUT:
|
||||||
|
message = "La demande de localisation a expiré."
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showLocationError(message)
|
||||||
|
} finally {
|
||||||
|
this.hideGetCurrentLocationLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise wrapper for geolocation
|
||||||
|
getCurrentPositionPromise(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(resolve, reject, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse geocode coordinates to get address
|
||||||
|
async reverseGeocode(lat, lng) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Reverse geocoding response:', data) // Debug log
|
||||||
|
|
||||||
|
if (data && data.display_name) {
|
||||||
|
return data.display_name
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Reverse geocoding failed:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview location - same as updating map links but with user feedback
|
||||||
|
previewLocation() {
|
||||||
|
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||||
|
this.showLocationError("Veuillez saisir une adresse pour la prévisualiser.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already have coordinates, just update map links
|
||||||
|
if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||||
|
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||||
|
this.updateMapLinks()
|
||||||
|
this.showLocationSuccess("Liens de carte mis à jour!")
|
||||||
|
} else {
|
||||||
|
// Otherwise geocode the address first
|
||||||
|
this.showPreviewLocationLoading()
|
||||||
|
this.geocodeAddress().finally(() => {
|
||||||
|
this.hidePreviewLocationLoading()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geocode address manually (with user feedback)
|
||||||
|
async geocodeAddress() {
|
||||||
|
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||||
|
this.showLocationError("Veuillez saisir une adresse.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.addressTarget.value.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isManualGeocodingInProgress = true
|
||||||
|
this.showLocationLoading()
|
||||||
|
const result = await this.performGeocode(address)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.latitudeTarget.value = result.lat
|
||||||
|
this.longitudeTarget.value = result.lng
|
||||||
|
this.updateMapLinks()
|
||||||
|
|
||||||
|
if (result.accuracy === 'exact') {
|
||||||
|
this.showLocationSuccess("Coordonnées exactes trouvées pour cette adresse!")
|
||||||
|
} else {
|
||||||
|
this.showLocationSuccess(`Coordonnées approximatives trouvées: ${result.display_name}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showLocationError("Erreur lors de la recherche de l'adresse.")
|
||||||
|
} finally {
|
||||||
|
this.isManualGeocodingInProgress = false
|
||||||
|
this.hideLocationLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geocode address quietly (no user feedback, for auto-geocoding)
|
||||||
|
async geocodeAddressQuiet(address) {
|
||||||
|
// Skip if address is too short or invalid
|
||||||
|
if (!address || address.length < 5) {
|
||||||
|
this.clearCoordinates()
|
||||||
|
this.clearMapLinks()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.performGeocode(address)
|
||||||
|
|
||||||
|
if (result && result.lat && result.lng) {
|
||||||
|
this.latitudeTarget.value = result.lat
|
||||||
|
this.longitudeTarget.value = result.lng
|
||||||
|
this.updateMapLinks()
|
||||||
|
console.log(`Auto-geocoded "${address}" to ${result.lat}, ${result.lng}`)
|
||||||
|
|
||||||
|
// Show success message based on accuracy
|
||||||
|
if (result.accuracy === 'exact') {
|
||||||
|
this.showGeocodingSuccess("Adresse géolocalisée avec précision", result.display_name)
|
||||||
|
} else {
|
||||||
|
this.showGeocodingSuccess("Adresse géolocalisée approximativement", result.display_name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If auto-geocoding fails, show a subtle warning
|
||||||
|
this.showGeocodingWarning(address)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Auto-geocoding failed:", error)
|
||||||
|
this.showGeocodingWarning(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual geocoding request with fallback strategies
|
||||||
|
async performGeocode(address) {
|
||||||
|
// Rate limiting: ensure at least 1 second between requests
|
||||||
|
const now = Date.now()
|
||||||
|
const timeSinceLastRequest = now - (this.constructor.lastGeocodingRequest || 0)
|
||||||
|
if (timeSinceLastRequest < 1000) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLastRequest))
|
||||||
|
}
|
||||||
|
this.constructor.lastGeocodingRequest = Date.now()
|
||||||
|
|
||||||
|
// Try multiple geocoding strategies
|
||||||
|
const strategies = [
|
||||||
|
// Strategy 1: Exact address
|
||||||
|
address,
|
||||||
|
// Strategy 2: Street name + city (remove house number)
|
||||||
|
address.replace(/^\d+\s*/, ''),
|
||||||
|
// Strategy 3: Just city and postal code
|
||||||
|
this.extractCityAndPostalCode(address)
|
||||||
|
].filter(Boolean) // Remove null/undefined values
|
||||||
|
|
||||||
|
for (let i = 0; i < strategies.length; i++) {
|
||||||
|
const searchAddress = strategies[i]
|
||||||
|
console.log(`Geocoding attempt ${i + 1}: "${searchAddress}"`)
|
||||||
|
|
||||||
|
// Show progress for manual geocoding (not auto-geocoding)
|
||||||
|
if (this.isManualGeocodingInProgress) {
|
||||||
|
const strategyNames = ['adresse complète', 'rue et ville', 'ville seulement']
|
||||||
|
this.showGeocodingProgress(strategyNames[i] || `stratégie ${i + 1}`, `${i + 1}/${strategies.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.tryGeocode(searchAddress)
|
||||||
|
if (result) {
|
||||||
|
console.log(`Geocoding successful with strategy ${i + 1}`)
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Strategy ${i + 1} failed:`, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add small delay between attempts
|
||||||
|
if (i < strategies.length - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
|
|
||||||
|
console.log('All geocoding strategies failed')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract city and postal code from address
|
||||||
|
extractCityAndPostalCode(address) {
|
||||||
|
// Look for French postal code pattern (5 digits) + city
|
||||||
|
const match = address.match(/(\d{5})\s+([^,]+)/);
|
||||||
|
if (match) {
|
||||||
|
return `${match[1]} ${match[2].trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract last part after comma (assume it's city)
|
||||||
|
const parts = address.split(',')
|
||||||
|
if (parts.length > 1) {
|
||||||
|
return parts[parts.length - 1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a single geocoding request
|
||||||
|
async tryGeocode(address) {
|
||||||
|
const encodedAddress = encodeURIComponent(address.trim())
|
||||||
|
|
||||||
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1&addressdetails=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const result = data[0]
|
||||||
|
return {
|
||||||
|
lat: parseFloat(result.lat).toFixed(6),
|
||||||
|
lng: parseFloat(result.lon).toFixed(6),
|
||||||
|
display_name: result.display_name,
|
||||||
|
accuracy: address === result.display_name ? 'exact' : 'approximate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update map links based on current coordinates
|
||||||
|
updateMapLinks() {
|
||||||
|
if (!this.hasMapLinksContainerTarget) return
|
||||||
|
|
||||||
|
const lat = parseFloat(this.latitudeTarget.value)
|
||||||
|
const lng = parseFloat(this.longitudeTarget.value)
|
||||||
|
const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
|
||||||
|
|
||||||
|
if (isNaN(lat) || isNaN(lng) || !address) {
|
||||||
|
this.clearMapLinks()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = this.generateMapLinks(lat, lng, address)
|
||||||
|
this.mapLinksContainerTarget.innerHTML = links
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate map links HTML
|
||||||
|
generateMapLinks(lat, lng, address) {
|
||||||
|
const encodedAddress = encodeURIComponent(address)
|
||||||
|
|
||||||
|
const providers = {
|
||||||
|
google: {
|
||||||
|
name: "Google Maps",
|
||||||
|
url: `https://www.google.com/maps/search/${encodedAddress},16z`,
|
||||||
|
icon: "🔍"
|
||||||
|
},
|
||||||
|
openstreetmap: {
|
||||||
|
name: "OpenStreetMap",
|
||||||
|
url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`,
|
||||||
|
icon: "🗺️"
|
||||||
|
},
|
||||||
|
apple: {
|
||||||
|
name: "Apple Plans",
|
||||||
|
url: `https://maps.apple.com/?address=${encodedAddress}&ll=${lat},${lng}`,
|
||||||
|
icon: "🍎"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Voir sur la carte :</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
${Object.entries(providers).map(([key, provider]) => `
|
||||||
|
<a href="${provider.url}" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<span class="mr-2">${provider.icon}</span>
|
||||||
|
${provider.name}
|
||||||
|
</a>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear coordinates
|
||||||
|
clearCoordinates() {
|
||||||
|
if (this.hasLatitudeTarget) this.latitudeTarget.value = ""
|
||||||
|
if (this.hasLongitudeTarget) this.longitudeTarget.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear map links
|
||||||
|
clearMapLinks() {
|
||||||
|
if (this.hasMapLinksContainerTarget) {
|
||||||
|
this.mapLinksContainerTarget.innerHTML = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding spinner in address input
|
||||||
|
showGeocodingSpinner() {
|
||||||
|
if (this.hasGeocodingSpinnerTarget) {
|
||||||
|
this.geocodingSpinnerTarget.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide geocoding spinner in address input
|
||||||
|
hideGeocodingSpinner() {
|
||||||
|
if (this.hasGeocodingSpinnerTarget) {
|
||||||
|
this.geocodingSpinnerTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state on "Ma position" button
|
||||||
|
showGetCurrentLocationLoading() {
|
||||||
|
if (this.hasGetCurrentLocationBtnTarget) {
|
||||||
|
this.getCurrentLocationBtnTarget.disabled = true
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationIconTarget) {
|
||||||
|
this.getCurrentLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-white border-t-transparent rounded-full animate-spin"></div>'
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationTextTarget) {
|
||||||
|
this.getCurrentLocationTextTarget.textContent = 'Localisation...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state on "Ma position" button
|
||||||
|
hideGetCurrentLocationLoading() {
|
||||||
|
if (this.hasGetCurrentLocationBtnTarget) {
|
||||||
|
this.getCurrentLocationBtnTarget.disabled = false
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationIconTarget) {
|
||||||
|
this.getCurrentLocationIconTarget.innerHTML = '<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>'
|
||||||
|
// Re-initialize Lucide icons
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationTextTarget) {
|
||||||
|
this.getCurrentLocationTextTarget.textContent = 'Ma position'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state on "Prévisualiser" button
|
||||||
|
showPreviewLocationLoading() {
|
||||||
|
if (this.hasPreviewLocationBtnTarget) {
|
||||||
|
this.previewLocationBtnTarget.disabled = true
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationIconTarget) {
|
||||||
|
this.previewLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-purple-700 border-t-transparent rounded-full animate-spin"></div>'
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationTextTarget) {
|
||||||
|
this.previewLocationTextTarget.textContent = 'Recherche...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state on "Prévisualiser" button
|
||||||
|
hidePreviewLocationLoading() {
|
||||||
|
if (this.hasPreviewLocationBtnTarget) {
|
||||||
|
this.previewLocationBtnTarget.disabled = false
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationIconTarget) {
|
||||||
|
this.previewLocationIconTarget.innerHTML = '<i data-lucide="map" class="w-3 h-3 mr-1"></i>'
|
||||||
|
// Re-initialize Lucide icons
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationTextTarget) {
|
||||||
|
this.previewLocationTextTarget.textContent = 'Prévisualiser'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
showLocationLoading() {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-loading", "Géolocalisation en cours...", "loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state
|
||||||
|
hideLocationLoading() {
|
||||||
|
this.hideMessage("location-loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showLocationSuccess(message) {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-success", message, "success")
|
||||||
|
setTimeout(() => this.hideMessage("location-success"), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
showLocationError(message) {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-error", message, "error")
|
||||||
|
setTimeout(() => this.hideMessage("location-error"), 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding warning (less intrusive than error)
|
||||||
|
showGeocodingWarning(address) {
|
||||||
|
this.hideMessage("geocoding-warning")
|
||||||
|
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
|
||||||
|
this.showMessage("geocoding-warning", message, "warning")
|
||||||
|
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show info about approximate location
|
||||||
|
showApproximateLocationInfo(foundLocation) {
|
||||||
|
this.hideMessage("approximate-location-info")
|
||||||
|
const message = `Localisation approximative trouvée: ${foundLocation}`
|
||||||
|
this.showMessage("approximate-location-info", message, "info")
|
||||||
|
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding success with location details
|
||||||
|
showGeocodingSuccess(title, location) {
|
||||||
|
this.hideMessage("geocoding-success")
|
||||||
|
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
||||||
|
this.showMessage("geocoding-success", message, "success")
|
||||||
|
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding progress with strategy info
|
||||||
|
showGeocodingProgress(strategy, attempt) {
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
|
const message = `Recherche en cours... (${attempt}/${strategy})`
|
||||||
|
this.showMessage("geocoding-progress", message, "loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message template configurations
|
||||||
|
getMessageTemplate(type) {
|
||||||
|
const templates = {
|
||||||
|
info: {
|
||||||
|
bgColor: "bg-blue-50",
|
||||||
|
borderColor: "border-blue-200",
|
||||||
|
textColor: "text-blue-800",
|
||||||
|
icon: "info",
|
||||||
|
iconColor: "text-blue-500"
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
bgColor: "bg-green-50",
|
||||||
|
borderColor: "border-green-200",
|
||||||
|
textColor: "text-green-800",
|
||||||
|
icon: "check-circle",
|
||||||
|
iconColor: "text-green-500"
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
borderColor: "border-red-200",
|
||||||
|
textColor: "text-red-800",
|
||||||
|
icon: "alert-circle",
|
||||||
|
iconColor: "text-red-500"
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bgColor: "bg-yellow-50",
|
||||||
|
borderColor: "border-yellow-200",
|
||||||
|
textColor: "text-yellow-800",
|
||||||
|
icon: "alert-triangle",
|
||||||
|
iconColor: "text-yellow-500"
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
bgColor: "bg-purple-50",
|
||||||
|
borderColor: "border-purple-200",
|
||||||
|
textColor: "text-purple-800",
|
||||||
|
icon: "loader-2",
|
||||||
|
iconColor: "text-purple-500",
|
||||||
|
animated: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return templates[type] || templates.info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dynamic message HTML using template
|
||||||
|
createMessageHTML(id, message, type) {
|
||||||
|
const template = this.getMessageTemplate(type)
|
||||||
|
const animationClass = template.animated ? 'animate-spin' : ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div id="${id}" class="flex items-start space-x-3 p-4 ${template.bgColor} ${template.borderColor} border rounded-lg shadow-sm transition-all duration-200 ease-in-out">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i data-lucide="${template.icon}" class="w-5 h-5 ${template.iconColor} ${animationClass}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium ${template.textColor} leading-relaxed">${message}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="flex-shrink-0 ${template.textColor} hover:opacity-70 transition-opacity">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a message with given type using template system
|
||||||
|
showMessage(id, message, type) {
|
||||||
|
// Remove existing message with same ID first
|
||||||
|
this.hideMessage(id)
|
||||||
|
|
||||||
|
const messageHtml = this.createMessageHTML(id, message, type)
|
||||||
|
|
||||||
|
// Insert into the dedicated messages container in the venue section
|
||||||
|
if (this.hasMessagesContainerTarget) {
|
||||||
|
this.messagesContainerTarget.insertAdjacentHTML('beforeend', messageHtml)
|
||||||
|
// Re-initialize Lucide icons for the new elements
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: insert before the address input if messages container not found
|
||||||
|
const addressInput = this.hasAddressTarget ? this.addressTarget.parentElement : null
|
||||||
|
if (addressInput) {
|
||||||
|
addressInput.insertAdjacentHTML('beforebegin', messageHtml)
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide a specific message
|
||||||
|
hideMessage(id) {
|
||||||
|
const element = document.getElementById(id)
|
||||||
|
if (element) {
|
||||||
|
element.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide all location messages
|
||||||
|
hideAllLocationMessages() {
|
||||||
|
this.hideMessage("location-loading")
|
||||||
|
this.hideMessage("location-success")
|
||||||
|
this.hideMessage("location-error")
|
||||||
|
this.hideMessage("geocoding-warning")
|
||||||
|
this.hideMessage("approximate-location-info")
|
||||||
|
this.hideMessage("geocoding-success")
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
// Controller for handling animations of featured event cards
|
|
||||||
// Uses intersection observer to trigger animations when cards come into view
|
|
||||||
export default class extends Controller {
|
|
||||||
// Define targets for the controller
|
|
||||||
static targets = ["card"]
|
|
||||||
|
|
||||||
// Define CSS classes that can be used with this controller
|
|
||||||
static classes = ["visible"]
|
|
||||||
|
|
||||||
// Define configurable values with defaults
|
|
||||||
static values = {
|
|
||||||
threshold: { type: Number, default: 0.1 }, // Percentage of element visibility needed to trigger animation
|
|
||||||
rootMargin: { type: String, default: '0px 0px -50px 0px' }, // Margin around root element for intersection detection
|
|
||||||
staggerDelay: { type: Number, default: 0.2 } // Delay between card animations in seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the controller when it connects to the DOM
|
|
||||||
connect() {
|
|
||||||
console.log("FeaturedEventController connected")
|
|
||||||
this.setupIntersectionObserver()
|
|
||||||
this.setupStaggeredAnimations()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up observers when the controller disconnects
|
|
||||||
disconnect() {
|
|
||||||
if (this.observer) {
|
|
||||||
this.observer.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up intersection observer to detect when cards come into view
|
|
||||||
setupIntersectionObserver() {
|
|
||||||
// Configure observer options
|
|
||||||
const observerOptions = {
|
|
||||||
threshold: this.thresholdValue,
|
|
||||||
rootMargin: this.rootMarginValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create intersection observer
|
|
||||||
this.observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
// Add visible class when card comes into view
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.classList.add('visible')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, observerOptions)
|
|
||||||
|
|
||||||
// Observe all card elements within this controller's scope
|
|
||||||
const elements = this.cardTargets
|
|
||||||
console.log("Card targets:", elements)
|
|
||||||
elements.forEach(el => {
|
|
||||||
this.observer.observe(el)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up staggered animations for cards with progressive delays
|
|
||||||
setupStaggeredAnimations() {
|
|
||||||
console.log("Setting up staggered animations")
|
|
||||||
console.log("Card targets:", this.cardTargets)
|
|
||||||
// Add staggered animation delays to cards
|
|
||||||
this.cardTargets.forEach((card, index) => {
|
|
||||||
card.style.transitionDelay = `${index * this.staggerDelayValue}s`
|
|
||||||
card.classList.remove('visible')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** Old code
|
|
||||||
<script>
|
|
||||||
// Add animation classes when elements are in view
|
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
|
||||||
const observerOptions = {
|
|
||||||
threshold: 0.1,
|
|
||||||
rootMargin: '0px 0px -50px 0px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.classList.add('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, observerOptions);
|
|
||||||
|
|
||||||
// Observe animated elements
|
|
||||||
document.querySelectorAll('.animate-fadeInUp, .animate-slideInLeft, .animate-slideInRight').forEach(el => {
|
|
||||||
observer.observe(el);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add staggered animation delays
|
|
||||||
document.querySelectorAll('.featured-event-card').forEach((card, index) => {
|
|
||||||
card.style.transitionDelay = `${index * 0.2}s`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
*/
|
|
||||||
@@ -19,8 +19,14 @@ application.register("ticket-selection", TicketSelectionController);
|
|||||||
import HeaderController from "./header_controller";
|
import HeaderController from "./header_controller";
|
||||||
application.register("header", HeaderController);
|
application.register("header", HeaderController);
|
||||||
|
|
||||||
|
import QrCodeController from "./qr_code_controller";
|
||||||
|
application.register("qr-code", QrCodeController);
|
||||||
|
|
||||||
import EventFormController from "./event_form_controller";
|
import EventFormController from "./event_form_controller";
|
||||||
application.register("event-form", EventFormController);
|
application.register("event-form", EventFormController);
|
||||||
|
|
||||||
import TicketTypeFormController from "./ticket_type_form_controller";
|
import CountdownController from "./countdown_controller";
|
||||||
application.register("ticket-type-form", TicketTypeFormController);
|
application.register("countdown", CountdownController);
|
||||||
|
|
||||||
|
import EventDuplicationController from "./event_duplication_controller";
|
||||||
|
application.register("event-duplication", EventDuplicationController);
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
// Connects to data-controller="lucide"
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["icon"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.initializeIcons()
|
|
||||||
|
|
||||||
// Listen for Turbo navigation events to reinitialize icons
|
|
||||||
document.addEventListener('turbo:render', this.handleTurboRender.bind(this))
|
|
||||||
document.addEventListener('turbo:frame-render', this.handleTurboFrameRender.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
// Clean up event listeners
|
|
||||||
document.removeEventListener('turbo:render', this.handleTurboRender.bind(this))
|
|
||||||
document.removeEventListener('turbo:frame-render', this.handleTurboFrameRender.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize all Lucide icons in the controller scope
|
|
||||||
initializeIcons() {
|
|
||||||
if (typeof lucide !== 'undefined') {
|
|
||||||
// Initialize icons within this controller's element
|
|
||||||
lucide.createIcons({
|
|
||||||
element: this.element
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.warn('Lucide not loaded yet, retrying...')
|
|
||||||
// Retry after a short delay if Lucide hasn't loaded yet
|
|
||||||
setTimeout(() => this.initializeIcons(), 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to reinitialize icons after dynamic content changes
|
|
||||||
reinitialize() {
|
|
||||||
this.initializeIcons()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to create a specific icon programmatically
|
|
||||||
createIcon(iconName, element = null) {
|
|
||||||
if (typeof lucide !== 'undefined') {
|
|
||||||
const targetElement = element || this.element
|
|
||||||
lucide.createIcons({
|
|
||||||
element: targetElement,
|
|
||||||
icons: {
|
|
||||||
[iconName]: lucide[iconName]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Turbo page renders
|
|
||||||
handleTurboRender() {
|
|
||||||
// Small delay to ensure DOM is fully updated
|
|
||||||
setTimeout(() => this.initializeIcons(), 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Turbo frame renders
|
|
||||||
handleTurboFrameRender(event) {
|
|
||||||
// Initialize icons within the specific frame that was rendered
|
|
||||||
if (event.detail && event.detail.newFrame) {
|
|
||||||
lucide.createIcons({
|
|
||||||
element: event.detail.newFrame
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
56
app/javascript/controllers/qr_code_controller.js
Normal file
56
app/javascript/controllers/qr_code_controller.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// QR Code generator controller using qrcode npm package
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import QRCode from "qrcode"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { data: String }
|
||||||
|
static targets = ["container", "loading"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.generateQRCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateQRCode() {
|
||||||
|
try {
|
||||||
|
// Hide loading indicator
|
||||||
|
if (this.hasLoadingTarget) {
|
||||||
|
this.loadingTarget.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create canvas element
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
|
||||||
|
// Generate QR code using qrcode library
|
||||||
|
await QRCode.toCanvas(canvas, this.dataValue, {
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear container and add QR code
|
||||||
|
this.containerTarget.innerHTML = ''
|
||||||
|
this.containerTarget.appendChild(canvas)
|
||||||
|
|
||||||
|
console.log('QR code generated successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating QR code:', error)
|
||||||
|
this.showFallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showFallback() {
|
||||||
|
this.containerTarget.innerHTML = `
|
||||||
|
<div class="w-32 h-32 bg-gray-100 rounded flex items-center justify-center text-gray-500 text-xs border-2 border-dashed border-gray-300">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-lg mb-1">📱</div>
|
||||||
|
<div>QR Code</div>
|
||||||
|
<div class="font-mono text-xs mt-1 break-all px-2">${this.dataValue}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import React from "react"
|
|
||||||
import { createRoot } from "react-dom/client"
|
|
||||||
import { Button } from "@/components/button"
|
|
||||||
|
|
||||||
// Controller for testing shadcn/ui React components within a Stimulus context
|
|
||||||
// Renders a React button component to verify the PostCSS and component setup
|
|
||||||
export default class extends Controller {
|
|
||||||
// Define targets for the controller
|
|
||||||
static targets = ["container"]
|
|
||||||
|
|
||||||
// Initialize and render the React component when the controller connects
|
|
||||||
connect() {
|
|
||||||
console.log("Shadcn Button Test Controller connected")
|
|
||||||
this.renderButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the React button component inside the target container
|
|
||||||
renderButton() {
|
|
||||||
const container = this.containerTarget
|
|
||||||
const root = createRoot(container)
|
|
||||||
|
|
||||||
root.render(
|
|
||||||
<div className="flex flex-col items-center gap-4 p-6">
|
|
||||||
<h3 className="text-white text-lg font-semibold">Test Button Shadcn</h3>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
|
|
||||||
onClick={this.handleClick}
|
|
||||||
>
|
|
||||||
Cliquez ici - PostCSS Test
|
|
||||||
</Button>
|
|
||||||
<p className="text-gray-300 text-sm">Ce bouton utilise shadcn/ui + Tailwind + PostCSS</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle button click events
|
|
||||||
handleClick = () => {
|
|
||||||
alert("✅ Le bouton shadcn fonctionne avec PostCSS !")
|
|
||||||
console.log("Shadcn button clicked - PostCSS compilation successful")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ export default class extends Controller {
|
|||||||
"checkoutButton",
|
"checkoutButton",
|
||||||
"form",
|
"form",
|
||||||
];
|
];
|
||||||
static values = { eventSlug: String, eventId: String };
|
static values = { eventSlug: String, eventId: String, orderNewUrl: String, storeCartUrl: String };
|
||||||
|
|
||||||
// Initialize the controller and update the cart summary
|
// Initialize the controller and update the cart summary
|
||||||
connect() {
|
connect() {
|
||||||
@@ -118,8 +118,8 @@ export default class extends Controller {
|
|||||||
await this.storeCartInSession(cartData);
|
await this.storeCartInSession(cartData);
|
||||||
|
|
||||||
// Redirect to event-scoped orders/new page
|
// Redirect to event-scoped orders/new page
|
||||||
const OrderNewUrl = `/events/${this.eventSlugValue}.${this.eventIdValue}/orders/new`;
|
const orderNewUrl = this.orderNewUrlValue;
|
||||||
window.location.href = OrderNewUrl;
|
window.location.href = orderNewUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error storing cart:", error);
|
console.error("Error storing cart:", error);
|
||||||
alert("Une erreur est survenue. Veuillez réessayer.");
|
alert("Une erreur est survenue. Veuillez réessayer.");
|
||||||
@@ -145,7 +145,7 @@ export default class extends Controller {
|
|||||||
|
|
||||||
// Store cart data in session via AJAX
|
// Store cart data in session via AJAX
|
||||||
async storeCartInSession(cartData) {
|
async storeCartInSession(cartData) {
|
||||||
const storeCartUrl = `/api/v1/events/${this.eventIdValue}/store_cart`;
|
const storeCartUrl = this.storeCartUrlValue;
|
||||||
|
|
||||||
const response = await fetch(storeCartUrl, {
|
const response = await fetch(storeCartUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -155,7 +155,7 @@ export default class extends Controller {
|
|||||||
.querySelector('meta[name="csrf-token"]')
|
.querySelector('meta[name="csrf-token"]')
|
||||||
.getAttribute("content"),
|
.getAttribute("content"),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ cart: cartData }),
|
body: JSON.stringify({ cart: cartData, event_id: this.eventIdValue }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
// Ticket Type Form Controller
|
|
||||||
// Handles dynamic pricing calculations and form interactions
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["price", "quantity", "total"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
console.log("Ticket type form controller connected")
|
|
||||||
this.updateTotal()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update total revenue calculation when price or quantity changes
|
|
||||||
updateTotal() {
|
|
||||||
const price = parseFloat(this.priceTarget.value) || 0
|
|
||||||
const quantity = parseInt(this.quantityTarget.value) || 0
|
|
||||||
const total = price * quantity
|
|
||||||
|
|
||||||
// Format as currency
|
|
||||||
const formatter = new Intl.NumberFormat('fr-FR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR',
|
|
||||||
minimumFractionDigits: 2
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.hasQuantityTarget && this.hasTotalTarget) {
|
|
||||||
// For new ticket types, calculate potential revenue
|
|
||||||
this.totalTarget.textContent = formatter.format(total)
|
|
||||||
} else if (this.hasTotalTarget) {
|
|
||||||
// For edit forms, calculate remaining potential revenue
|
|
||||||
const soldTickets = parseInt(this.element.dataset.soldTickets) || 0
|
|
||||||
const remainingQuantity = Math.max(0, quantity - soldTickets)
|
|
||||||
const remainingRevenue = price * remainingQuantity
|
|
||||||
this.totalTarget.textContent = formatter.format(remainingRevenue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate minimum quantity (for edit forms with sold tickets)
|
|
||||||
validateQuantity() {
|
|
||||||
const soldTickets = parseInt(this.element.dataset.soldTickets) || 0
|
|
||||||
const quantity = parseInt(this.quantityTarget.value) || 0
|
|
||||||
|
|
||||||
if (quantity < soldTickets) {
|
|
||||||
this.quantityTarget.value = soldTickets
|
|
||||||
this.quantityTarget.setCustomValidity(`La quantité ne peut pas être inférieure à ${soldTickets} (billets déjà vendus)`)
|
|
||||||
} else {
|
|
||||||
this.quantityTarget.setCustomValidity('')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateTotal()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format price input to ensure proper decimal places
|
|
||||||
formatPrice() {
|
|
||||||
const price = parseFloat(this.priceTarget.value)
|
|
||||||
if (!isNaN(price)) {
|
|
||||||
this.priceTarget.value = price.toFixed(2)
|
|
||||||
}
|
|
||||||
this.updateTotal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
// Utility function for conditionally joining CSS classes
|
|
||||||
// Combines clsx (for conditional classes) with twMerge (for Tailwind CSS conflicts)
|
|
||||||
// Usage: cn("class1", "class2", conditionalClass && "class3")
|
|
||||||
export function cn(...inputs) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
|
# Background job to clean up expired draft tickets
|
||||||
|
#
|
||||||
|
# This job runs periodically to find and expire draft tickets that have
|
||||||
|
# passed their expiry time (typically 30 minutes after creation).
|
||||||
|
# Should be scheduled via cron or similar scheduling system.
|
||||||
class CleanupExpiredDraftsJob < ApplicationJob
|
class CleanupExpiredDraftsJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
|
|
||||||
|
# Find and expire all draft tickets that have passed their expiry time
|
||||||
|
#
|
||||||
|
# Uses find_each to process tickets in batches to avoid memory issues
|
||||||
|
# with large datasets. Continues processing even if individual tickets fail.
|
||||||
def perform
|
def perform
|
||||||
expired_count = 0
|
expired_count = 0
|
||||||
|
|
||||||
|
# Process expired draft tickets in batches
|
||||||
Ticket.expired_drafts.find_each do |ticket|
|
Ticket.expired_drafts.find_each do |ticket|
|
||||||
Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user_id}"
|
begin
|
||||||
|
Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user.id}"
|
||||||
ticket.expire_if_overdue!
|
ticket.expire_if_overdue!
|
||||||
expired_count += 1
|
expired_count += 1
|
||||||
|
rescue => e
|
||||||
|
# Log error but continue processing other tickets
|
||||||
|
Rails.logger.error "Failed to expire ticket #{ticket.id}: #{e.message}"
|
||||||
|
next
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Log summary if any tickets were processed
|
||||||
Rails.logger.info "Expired #{expired_count} draft tickets" if expired_count > 0
|
Rails.logger.info "Expired #{expired_count} draft tickets" if expired_count > 0
|
||||||
|
Rails.logger.info "No expired draft tickets found" if expired_count == 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
19
app/jobs/event_reminder_job.rb
Normal file
19
app/jobs/event_reminder_job.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
class EventReminderJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(event_id, days_before)
|
||||||
|
event = Event.find(event_id)
|
||||||
|
|
||||||
|
# Find all users with active tickets for this event
|
||||||
|
users_with_tickets = User.joins(orders: { tickets: :ticket_type })
|
||||||
|
.where(ticket_types: { event: event })
|
||||||
|
.where(tickets: { status: "active" })
|
||||||
|
.distinct
|
||||||
|
|
||||||
|
users_with_tickets.find_each do |user|
|
||||||
|
TicketMailer.event_reminder(user, event, days_before).deliver_now
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Failed to send event reminder to user #{user.id} for event #{event.id}: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
44
app/jobs/event_reminder_scheduler_job.rb
Normal file
44
app/jobs/event_reminder_scheduler_job.rb
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
class EventReminderSchedulerJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
schedule_weekly_reminders
|
||||||
|
schedule_daily_reminders
|
||||||
|
schedule_day_of_reminders
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def schedule_weekly_reminders
|
||||||
|
# Find events starting in exactly 7 days
|
||||||
|
target_date = 7.days.from_now.beginning_of_day
|
||||||
|
events = Event.published
|
||||||
|
.where(start_time: target_date..(target_date + 1.day))
|
||||||
|
|
||||||
|
events.find_each do |event|
|
||||||
|
EventReminderJob.perform_later(event.id, 7)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def schedule_daily_reminders
|
||||||
|
# Find events starting in exactly 1 day (tomorrow)
|
||||||
|
target_date = 1.day.from_now.beginning_of_day
|
||||||
|
events = Event.published
|
||||||
|
.where(start_time: target_date..(target_date + 1.day))
|
||||||
|
|
||||||
|
events.find_each do |event|
|
||||||
|
EventReminderJob.perform_later(event.id, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def schedule_day_of_reminders
|
||||||
|
# Find events starting today
|
||||||
|
target_date = Time.current.beginning_of_day
|
||||||
|
events = Event.published
|
||||||
|
.where(start_time: target_date..(target_date + 1.day))
|
||||||
|
|
||||||
|
events.find_each do |event|
|
||||||
|
EventReminderJob.perform_later(event.id, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
49
app/jobs/stripe_invoice_generation_job.rb
Normal file
49
app/jobs/stripe_invoice_generation_job.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Background job to create Stripe invoices for accounting records
|
||||||
|
#
|
||||||
|
# This job is responsible for creating post-payment invoices in Stripe
|
||||||
|
# for accounting purposes after a successful payment
|
||||||
|
class StripeInvoiceGenerationJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# Retry up to 3 times with exponential backoff
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
# Don't retry on Stripe authentication errors
|
||||||
|
discard_on Stripe::AuthenticationError
|
||||||
|
|
||||||
|
def perform(order_id)
|
||||||
|
order = Order.find(order_id)
|
||||||
|
|
||||||
|
unless order.status == "paid"
|
||||||
|
Rails.logger.warn "Attempted to create invoice for unpaid order #{order_id}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create the Stripe invoice
|
||||||
|
service = StripeInvoiceService.new(order)
|
||||||
|
stripe_invoice = service.create_post_payment_invoice
|
||||||
|
|
||||||
|
if stripe_invoice
|
||||||
|
# Store the invoice ID (you might want to persist this in the database)
|
||||||
|
order.instance_variable_set(:@stripe_invoice_id, stripe_invoice.id)
|
||||||
|
|
||||||
|
Rails.logger.info "Successfully created Stripe invoice #{stripe_invoice.id} for order #{order.id} via background job"
|
||||||
|
|
||||||
|
# Optionally send notification email about invoice availability
|
||||||
|
# InvoiceMailer.invoice_ready(order, stripe_invoice.id).deliver_now
|
||||||
|
else
|
||||||
|
error_msg = service.errors.join(", ")
|
||||||
|
Rails.logger.error "Failed to create Stripe invoice for order #{order.id}: #{error_msg}"
|
||||||
|
raise StandardError, "Invoice generation failed: #{error_msg}"
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
Rails.logger.error "Order #{order_id} not found for invoice generation"
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
Rails.logger.error "Stripe error creating invoice for order #{order_id}: #{e.message}"
|
||||||
|
raise e # Re-raise to trigger retry logic
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Unexpected error creating invoice for order #{order_id}: #{e.message}"
|
||||||
|
raise e # Re-raise to trigger retry logic
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: "from@example.com"
|
default from: ENV.fetch("MAILER_FROM_EMAIL", "no-reply@aperonight.fr")
|
||||||
layout "mailer"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
class TicketMailer < ApplicationMailer
|
class TicketMailer < ApplicationMailer
|
||||||
default from: 'notifications@aperonight.com'
|
helper :tickets
|
||||||
|
|
||||||
|
def purchase_confirmation_order(order)
|
||||||
|
@order = order
|
||||||
|
@user = order.user
|
||||||
|
@event = order.event
|
||||||
|
@tickets = order.tickets
|
||||||
|
|
||||||
|
# Generate PDF attachments for all tickets
|
||||||
|
@tickets.each do |ticket|
|
||||||
|
begin
|
||||||
|
pdf = ticket.to_pdf
|
||||||
|
attachments["ticket-#{@event.name.parameterize}-#{ticket.qr_code[0..7]}.pdf"] = {
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
content: pdf
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Failed to generate PDF for ticket #{ticket.id}: #{e.message}"
|
||||||
|
# Continue without PDF attachment rather than failing the entire email
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mail(
|
||||||
|
to: @user.email,
|
||||||
|
subject: "Confirmation d'achat - #{@event.name}",
|
||||||
|
template_name: "purchase_confirmation"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def purchase_confirmation(ticket)
|
def purchase_confirmation(ticket)
|
||||||
@ticket = ticket
|
@ticket = ticket
|
||||||
@@ -7,15 +34,49 @@ 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
|
||||||
16
app/models/earning.rb
Normal file
16
app/models/earning.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class Earning < ApplicationRecord
|
||||||
|
# === Relations ===
|
||||||
|
belongs_to :event
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :order
|
||||||
|
|
||||||
|
# === Enums ===
|
||||||
|
enum :status, { pending: 0, paid: 1 }
|
||||||
|
|
||||||
|
# === Validations ===
|
||||||
|
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :net_amount_cents, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
|
||||||
|
validates :status, presence: true
|
||||||
|
validates :stripe_payout_id, allow_blank: true, uniqueness: true
|
||||||
|
end
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# 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
|
||||||
@@ -13,17 +16,31 @@ class Event < ApplicationRecord
|
|||||||
sold_out: 3
|
sold_out: 3
|
||||||
}, default: :draft
|
}, default: :draft
|
||||||
|
|
||||||
|
enum :payout_status, {
|
||||||
|
not_requested: 0,
|
||||||
|
requested: 1,
|
||||||
|
processing: 2,
|
||||||
|
completed: 3,
|
||||||
|
failed: 4
|
||||||
|
}, default: :not_requested
|
||||||
|
|
||||||
# === Relations ===
|
# === Relations ===
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :ticket_types
|
has_many :ticket_types
|
||||||
has_many :tickets, through: :ticket_types
|
has_many :tickets, through: :ticket_types
|
||||||
has_many :orders
|
has_many :orders
|
||||||
|
has_many :earnings, dependent: :destroy
|
||||||
|
has_many :payouts, dependent: :destroy
|
||||||
|
|
||||||
|
# === Callbacks ===
|
||||||
|
before_validation :geocode_address, if: :should_geocode_address?
|
||||||
|
|
||||||
|
# === Validations ===
|
||||||
|
|
||||||
# Validations for Event attributes
|
|
||||||
# Basic information
|
# 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: 1000 }
|
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
|
||||||
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
|
||||||
|
|
||||||
@@ -49,4 +66,235 @@ 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 ===
|
||||||
|
|
||||||
|
# Payout methods
|
||||||
|
def can_request_payout?
|
||||||
|
event_ended? && earnings.pending.any? && user.can_receive_payouts?
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_earnings_cents
|
||||||
|
# Only count earnings from non-refunded tickets
|
||||||
|
earnings.pending.sum(:amount_cents)
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_fees_cents
|
||||||
|
(total_earnings_cents * 0.1).to_i # 10% platform fee
|
||||||
|
end
|
||||||
|
|
||||||
|
def net_earnings_cents
|
||||||
|
total_earnings_cents - total_fees_cents
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||||
|
def geocoding_successful?
|
||||||
|
coordinates_look_valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a user-friendly status message about geocoding
|
||||||
|
def geocoding_status_message
|
||||||
|
return nil if geocoding_successful?
|
||||||
|
|
||||||
|
"Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if ticket booking is currently allowed for this event
|
||||||
|
def booking_allowed?
|
||||||
|
return false unless published?
|
||||||
|
return false if sold_out?
|
||||||
|
return false if canceled?
|
||||||
|
|
||||||
|
# Check if event has started and if booking during event is disabled
|
||||||
|
if event_started? && !allow_booking_during_event?
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the event has already started
|
||||||
|
def event_started?
|
||||||
|
return false if start_time.blank?
|
||||||
|
Time.current >= start_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the event has ended
|
||||||
|
def event_ended?
|
||||||
|
return false if end_time.blank?
|
||||||
|
Time.current >= end_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if booking is allowed during the event
|
||||||
|
# This is a simple attribute reader that defaults to false if nil
|
||||||
|
def allow_booking_during_event?
|
||||||
|
!!allow_booking_during_event
|
||||||
|
end
|
||||||
|
|
||||||
|
# Duplicate an event with all its ticket types
|
||||||
|
def duplicate(clone_ticket_types: true)
|
||||||
|
# Duplicate the event
|
||||||
|
new_event = self.dup
|
||||||
|
new_event.name = "Copie de #{name}"
|
||||||
|
new_event.slug = "#{slug}-copy-#{Time.current.to_i}"
|
||||||
|
new_event.state = :draft
|
||||||
|
new_event.created_at = Time.current
|
||||||
|
new_event.updated_at = Time.current
|
||||||
|
|
||||||
|
Event.transaction do
|
||||||
|
if new_event.save
|
||||||
|
# Duplicate all ticket types if requested
|
||||||
|
if clone_ticket_types
|
||||||
|
ticket_types.each do |ticket_type|
|
||||||
|
new_ticket_type = ticket_type.dup
|
||||||
|
new_ticket_type.event = new_event
|
||||||
|
new_ticket_type.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
new_event
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Determine if we should perform server-side geocoding
|
||||||
|
def should_geocode_address?
|
||||||
|
# Don't geocode if address is blank
|
||||||
|
return false if venue_address.blank?
|
||||||
|
|
||||||
|
# Don't geocode if we already have valid coordinates (likely from frontend)
|
||||||
|
return false if coordinates_look_valid?
|
||||||
|
|
||||||
|
# Only geocode if address changed and we don't have coordinates
|
||||||
|
venue_address_changed?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the current coordinates look like they were set by frontend geocoding
|
||||||
|
def coordinates_look_valid?
|
||||||
|
return false if latitude.blank? || longitude.blank?
|
||||||
|
|
||||||
|
lat_f = latitude.to_f
|
||||||
|
lng_f = longitude.to_f
|
||||||
|
|
||||||
|
# Basic sanity checks for coordinate ranges
|
||||||
|
return false if lat_f < -90 || lat_f > 90
|
||||||
|
return false if lng_f < -180 || lng_f > 180
|
||||||
|
|
||||||
|
# Check if coordinates are not the default fallback coordinates
|
||||||
|
fallback_lat = 46.603354
|
||||||
|
fallback_lng = 1.888334
|
||||||
|
|
||||||
|
# Check if coordinates are not exactly 0,0 (common invalid default)
|
||||||
|
return false if lat_f == 0.0 && lng_f == 0.0
|
||||||
|
|
||||||
|
# Coordinates are valid if they're not exactly the fallback coordinates
|
||||||
|
!(lat_f == fallback_lat && lng_f == fallback_lng)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Automatically geocode address to get latitude and longitude
|
||||||
|
# This only runs when no valid coordinates are provided (fallback for non-JS users)
|
||||||
|
def geocode_address
|
||||||
|
Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)"
|
||||||
|
|
||||||
|
# Store original coordinates in case we need to fall back
|
||||||
|
original_lat = latitude
|
||||||
|
original_lng = longitude
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Use OpenStreetMap Nominatim API for geocoding
|
||||||
|
encoded_address = URI.encode_www_form_component(venue_address.strip)
|
||||||
|
uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1")
|
||||||
|
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.use_ssl = true
|
||||||
|
|
||||||
|
request = Net::HTTP::Get.new(uri)
|
||||||
|
request["User-Agent"] = "AperoNight Event Platform/1.0 (https://aperonight.com)"
|
||||||
|
request["Accept"] = "application/json"
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
|
||||||
|
if response.code == "200"
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
|
||||||
|
if data.any?
|
||||||
|
result = data.first
|
||||||
|
self.latitude = result["lat"].to_f.round(6)
|
||||||
|
self.longitude = result["lon"].to_f.round(6)
|
||||||
|
Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# If we reach here, geocoding failed
|
||||||
|
handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}"
|
||||||
|
handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle geocoding failure with fallback strategies
|
||||||
|
def handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
# Strategy 1: Keep existing coordinates if this is an update
|
||||||
|
if original_lat.present? && original_lng.present?
|
||||||
|
self.latitude = original_lat
|
||||||
|
self.longitude = original_lng
|
||||||
|
Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strategy 2: Try to extract country/city and use approximate coordinates
|
||||||
|
fallback_coordinates = get_fallback_coordinates_from_address
|
||||||
|
if fallback_coordinates
|
||||||
|
self.latitude = fallback_coordinates[:lat]
|
||||||
|
self.longitude = fallback_coordinates[:lng]
|
||||||
|
Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strategy 3: Use default coordinates (center of France) as last resort
|
||||||
|
# This ensures the event can still be created
|
||||||
|
self.latitude = 46.603354 # Center of France
|
||||||
|
self.longitude = 1.888334
|
||||||
|
Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract country/city from address and return approximate coordinates
|
||||||
|
def get_fallback_coordinates_from_address
|
||||||
|
address_lower = venue_address.downcase
|
||||||
|
|
||||||
|
# Common French cities with approximate coordinates
|
||||||
|
french_cities = {
|
||||||
|
"paris" => { lat: 48.8566, lng: 2.3522 },
|
||||||
|
"lyon" => { lat: 45.7640, lng: 4.8357 },
|
||||||
|
"marseille" => { lat: 43.2965, lng: 5.3698 },
|
||||||
|
"toulouse" => { lat: 43.6047, lng: 1.4442 },
|
||||||
|
"nice" => { lat: 43.7102, lng: 7.2620 },
|
||||||
|
"nantes" => { lat: 47.2184, lng: -1.5536 },
|
||||||
|
"montpellier" => { lat: 43.6110, lng: 3.8767 },
|
||||||
|
"strasbourg" => { lat: 48.5734, lng: 7.7521 },
|
||||||
|
"bordeaux" => { lat: 44.8378, lng: -0.5792 },
|
||||||
|
"lille" => { lat: 50.6292, lng: 3.0573 }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if any known city is mentioned in the address
|
||||||
|
french_cities.each do |city, coords|
|
||||||
|
if address_lower.include?(city)
|
||||||
|
return coords
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check for common country indicators
|
||||||
|
if address_lower.include?("france") || address_lower.include?("french")
|
||||||
|
return { lat: 46.603354, lng: 1.888334 } # Center of France
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class Order < ApplicationRecord
|
class Order < ApplicationRecord
|
||||||
# === Constants ===
|
# === Constants ===
|
||||||
DRAFT_EXPIRY_TIME = 30.minutes
|
DRAFT_EXPIRY_TIME = 15.minutes
|
||||||
MAX_PAYMENT_ATTEMPTS = 3
|
MAX_PAYMENT_ATTEMPTS = 3
|
||||||
|
|
||||||
# === Associations ===
|
# === Associations ===
|
||||||
@@ -19,6 +19,9 @@ class Order < ApplicationRecord
|
|||||||
validates :payment_attempts, presence: true,
|
validates :payment_attempts, presence: true,
|
||||||
numericality: { greater_than_or_equal_to: 0 }
|
numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
|
||||||
|
# Stripe invoice ID for accounting records
|
||||||
|
attr_accessor :stripe_invoice_id
|
||||||
|
|
||||||
# === Scopes ===
|
# === Scopes ===
|
||||||
scope :draft, -> { where(status: "draft") }
|
scope :draft, -> { where(status: "draft") }
|
||||||
scope :active, -> { where(status: %w[paid completed]) }
|
scope :active, -> { where(status: %w[paid completed]) }
|
||||||
@@ -29,6 +32,7 @@ class Order < ApplicationRecord
|
|||||||
}
|
}
|
||||||
|
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
|
||||||
|
|
||||||
# === Instance Methods ===
|
# === Instance Methods ===
|
||||||
|
|
||||||
@@ -73,11 +77,80 @@ class Order < ApplicationRecord
|
|||||||
update!(status: "paid")
|
update!(status: "paid")
|
||||||
tickets.update_all(status: "active")
|
tickets.update_all(status: "active")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# Calculate total from tickets
|
# Calculate total from ticket prices only (platform fee deducted from promoter payout)
|
||||||
def calculate_total!
|
def calculate_total!
|
||||||
update!(total_amount_cents: tickets.sum(:price_cents))
|
ticket_total = tickets.sum(:price_cents)
|
||||||
|
update!(total_amount_cents: ticket_total)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
|
||||||
|
def platform_fee_cents
|
||||||
|
tickets.sum do |ticket|
|
||||||
|
fixed_fee = 50 # €0.50 in cents
|
||||||
|
percentage_fee = (ticket.price_cents * 0.015).to_i
|
||||||
|
fixed_fee + percentage_fee
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Promoter payout amount after platform fee deduction
|
||||||
|
def promoter_payout_cents
|
||||||
|
total_amount_cents - platform_fee_cents
|
||||||
|
end
|
||||||
|
|
||||||
|
def platform_fee_euros
|
||||||
|
platform_fee_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
def promoter_payout_euros
|
||||||
|
promoter_payout_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if order contains only free tickets
|
||||||
|
def free?
|
||||||
|
total_amount_cents == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create Stripe invoice for accounting records
|
||||||
|
#
|
||||||
|
# This method creates a post-payment invoice in Stripe for accounting purposes
|
||||||
|
# It should only be called after the order has been paid
|
||||||
|
#
|
||||||
|
# @return [String, nil] The Stripe invoice ID or nil if creation failed
|
||||||
|
def create_stripe_invoice!
|
||||||
|
return nil unless status == "paid"
|
||||||
|
return @stripe_invoice_id if @stripe_invoice_id.present?
|
||||||
|
|
||||||
|
service = StripeInvoiceService.new(self)
|
||||||
|
stripe_invoice = service.create_post_payment_invoice
|
||||||
|
|
||||||
|
if stripe_invoice
|
||||||
|
@stripe_invoice_id = stripe_invoice.id
|
||||||
|
Rails.logger.info "Created Stripe invoice #{stripe_invoice.id} for order #{id}"
|
||||||
|
stripe_invoice.id
|
||||||
|
else
|
||||||
|
Rails.logger.error "Failed to create Stripe invoice for order #{id}: #{service.errors.join(', ')}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the Stripe invoice PDF URL if available
|
||||||
|
#
|
||||||
|
# @return [String, nil] The PDF URL or nil if not available
|
||||||
|
def stripe_invoice_pdf_url
|
||||||
|
return nil unless @stripe_invoice_id.present?
|
||||||
|
StripeInvoiceService.get_invoice_pdf_url(@stripe_invoice_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -90,4 +163,17 @@ class Order < ApplicationRecord
|
|||||||
def draft?
|
def draft?
|
||||||
status == "draft"
|
status == "draft"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_earnings_if_paid
|
||||||
|
return unless event.present? && user.present?
|
||||||
|
return if event.earnings.exists?(order_id: id)
|
||||||
|
|
||||||
|
event.earnings.create!(
|
||||||
|
user: user,
|
||||||
|
order: self,
|
||||||
|
amount_cents: promoter_payout_cents,
|
||||||
|
fee_cents: platform_fee_cents,
|
||||||
|
status: :pending
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
59
app/models/payout.rb
Normal file
59
app/models/payout.rb
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
class Payout < ApplicationRecord
|
||||||
|
# === Relations ===
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :event
|
||||||
|
|
||||||
|
# === Enums ===
|
||||||
|
enum :status, {
|
||||||
|
pending: 0, # Payout requested but not processed
|
||||||
|
processing: 1, # Payout being processed
|
||||||
|
completed: 2, # Payout successfully completed
|
||||||
|
failed: 3 # Payout failed
|
||||||
|
}, default: :pending
|
||||||
|
|
||||||
|
# === Validations ===
|
||||||
|
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :status, presence: true
|
||||||
|
validates :total_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :refunded_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :stripe_payout_id, allow_blank: true, uniqueness: true
|
||||||
|
|
||||||
|
# === Scopes ===
|
||||||
|
scope :completed, -> { where(status: :completed) }
|
||||||
|
scope :pending, -> { where(status: :pending) }
|
||||||
|
scope :processing, -> { where(status: :processing) }
|
||||||
|
|
||||||
|
# === Instance Methods ===
|
||||||
|
|
||||||
|
# Amount in euros (formatted)
|
||||||
|
def amount_euros
|
||||||
|
amount_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fee in euros (formatted)
|
||||||
|
def fee_euros
|
||||||
|
fee_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Net amount after fees
|
||||||
|
def net_amount_cents
|
||||||
|
amount_cents - fee_cents
|
||||||
|
end
|
||||||
|
|
||||||
|
# Net amount in euros
|
||||||
|
def net_amount_euros
|
||||||
|
net_amount_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if payout can be processed
|
||||||
|
def can_process?
|
||||||
|
pending? && amount_cents > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process the payout through Stripe
|
||||||
|
def process_payout!
|
||||||
|
service = PayoutService.new(self)
|
||||||
|
service.process!
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,7 @@ class Ticket < ApplicationRecord
|
|||||||
validates :qr_code, presence: true, uniqueness: true
|
validates :qr_code, presence: true, uniqueness: true
|
||||||
validates :order_id, presence: true
|
validates :order_id, presence: true
|
||||||
validates :ticket_type_id, presence: true
|
validates :ticket_type_id, presence: true
|
||||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
||||||
validates :first_name, presence: true
|
validates :first_name, presence: true
|
||||||
validates :last_name, presence: true
|
validates :last_name, presence: true
|
||||||
@@ -17,6 +17,7 @@ class Ticket < ApplicationRecord
|
|||||||
# === Scopes ===
|
# === Scopes ===
|
||||||
scope :draft, -> { where(status: "draft") }
|
scope :draft, -> { where(status: "draft") }
|
||||||
scope :active, -> { where(status: "active") }
|
scope :active, -> { where(status: "active") }
|
||||||
|
scope :expired_drafts, -> { 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
|
||||||
@@ -69,7 +70,6 @@ class Ticket < ApplicationRecord
|
|||||||
self.qr_code = "#{id || 'temp'}-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
self.qr_code = "#{id || 'temp'}-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
def draft?
|
def draft?
|
||||||
status == "draft"
|
status == "draft"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
class TicketType < ApplicationRecord
|
class TicketType < ApplicationRecord
|
||||||
# Associations
|
# Associations
|
||||||
belongs_to :event
|
belongs_to :event
|
||||||
has_many :tickets, dependent: :destroy
|
has_many :tickets, dependent: :destroy # Cannot delete ticket types if already tickets sold
|
||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
validates :name, presence: true, length: { minimum: 3, maximum: 50 }
|
validates :name, presence: true, length: { minimum: 3, maximum: 50 }
|
||||||
validates :description, presence: true, length: { minimum: 10, maximum: 500 }
|
validates :description, presence: true, length: { minimum: 10, maximum: 500 }
|
||||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
validates :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
|
||||||
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] }
|
validates :requires_id, inclusion: { in: [ true, false ] }
|
||||||
|
|
||||||
# Custom validations
|
# Custom validations
|
||||||
validate :sale_end_after_start
|
validate :sale_end_after_start
|
||||||
@@ -45,7 +45,11 @@ class TicketType < ApplicationRecord
|
|||||||
|
|
||||||
def available_quantity
|
def available_quantity
|
||||||
return 0 if quantity.nil?
|
return 0 if quantity.nil?
|
||||||
[quantity - tickets.count, 0].max
|
[ quantity - tickets.count, 0 ].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def free?
|
||||||
|
price_cents == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def sales_status
|
def sales_status
|
||||||
@@ -53,7 +57,7 @@ class TicketType < ApplicationRecord
|
|||||||
return :expired if sale_end_at < Time.current
|
return :expired if sale_end_at < Time.current
|
||||||
return :upcoming if sale_start_at > Time.current
|
return :upcoming if sale_start_at > Time.current
|
||||||
return :sold_out if sold_out?
|
return :sold_out if sold_out?
|
||||||
return :available
|
:available
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_potential_revenue
|
def total_potential_revenue
|
||||||
@@ -80,6 +84,10 @@ class TicketType < ApplicationRecord
|
|||||||
|
|
||||||
def sale_times_within_event_period
|
def sale_times_within_event_period
|
||||||
return unless event&.start_time && sale_end_at
|
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
|
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,21 +23,48 @@ class User < ApplicationRecord
|
|||||||
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
|
has_many :orders, dependent: :destroy
|
||||||
|
has_many :earnings, dependent: :destroy
|
||||||
|
has_many :payouts, dependent: :destroy
|
||||||
|
|
||||||
# Validations
|
# Validations - allow reasonable name lengths
|
||||||
validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||||
validates :first_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||||
validates :company_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
|
||||||
|
|
||||||
|
# Onboarding methods
|
||||||
|
def needs_onboarding?
|
||||||
|
!onboarding_completed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete_onboarding!
|
||||||
|
update!(onboarding_completed: true)
|
||||||
|
end
|
||||||
|
|
||||||
# Authorization methods
|
# Authorization methods
|
||||||
def can_manage_events?
|
def can_manage_events?
|
||||||
# For now, all authenticated users can manage events
|
# Only professional users can manage events
|
||||||
# This can be extended later with role-based permissions
|
is_professionnal?
|
||||||
true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def promoter?
|
def promoter?
|
||||||
# Alias for can_manage_events? to make views more semantic
|
# Alias for can_manage_events? to make views more semantic
|
||||||
can_manage_events?
|
can_manage_events?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def name
|
||||||
|
[ first_name, last_name ].compact.join(" ").strip
|
||||||
|
end
|
||||||
|
|
||||||
|
# Stripe Connect methods
|
||||||
|
def stripe_account_id
|
||||||
|
stripe_connected_account_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_stripe_account?
|
||||||
|
stripe_connected_account_id.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_receive_payouts?
|
||||||
|
has_stripe_account? && promoter?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
30
app/services/payout_service.rb
Normal file
30
app/services/payout_service.rb
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
class PayoutService
|
||||||
|
def initialize(payout)
|
||||||
|
@payout = payout
|
||||||
|
end
|
||||||
|
|
||||||
|
def process!
|
||||||
|
return unless @payout.can_process?
|
||||||
|
|
||||||
|
@payout.update!(status: :processing)
|
||||||
|
|
||||||
|
# Create Stripe payout
|
||||||
|
begin
|
||||||
|
stripe_payout = Stripe::Payout.create({
|
||||||
|
amount: @payout.amount_cents,
|
||||||
|
currency: 'eur',
|
||||||
|
destination: @payout.user.stripe_account_id,
|
||||||
|
description: "Payout for event: #{@payout.event.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
@payout.update!(
|
||||||
|
status: :completed,
|
||||||
|
stripe_payout_id: stripe_payout.id
|
||||||
|
)
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
@payout.update!(status: :failed)
|
||||||
|
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
35
app/services/stripe_connect_service.rb
Normal file
35
app/services/stripe_connect_service.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
class StripeConnectService
|
||||||
|
def self.create_account(user)
|
||||||
|
return if user.stripe_connected_account_id.present?
|
||||||
|
|
||||||
|
account = Stripe::Account.create(
|
||||||
|
type: "express",
|
||||||
|
country: "FR",
|
||||||
|
email: user.email,
|
||||||
|
capabilities: {
|
||||||
|
card_payments: { requested: true },
|
||||||
|
transfers: { requested: true }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
user.update!(stripe_connected_account_id: account.id)
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.onboarding_link(user)
|
||||||
|
return unless user.stripe_connected_account_id.present?
|
||||||
|
|
||||||
|
account_link = Stripe::AccountLink.create(
|
||||||
|
account: user.stripe_connected_account_id,
|
||||||
|
refresh_url: Rails.application.routes.url_helpers.promoter_stripe_refresh_url,
|
||||||
|
return_url: Rails.application.routes.url_helpers.promoter_stripe_return_url,
|
||||||
|
type: "account_onboarding"
|
||||||
|
)
|
||||||
|
|
||||||
|
account_link.url
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.get_account_details(account_id)
|
||||||
|
Stripe::Account.retrieve(account_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
209
app/services/stripe_invoice_service.rb
Normal file
209
app/services/stripe_invoice_service.rb
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Service to create Stripe invoices for accounting records after successful payment
|
||||||
|
#
|
||||||
|
# This service creates post-payment invoices in Stripe for accounting purposes.
|
||||||
|
# Unlike regular Stripe invoices which are used for collection, these are
|
||||||
|
# created after payment via Checkout Sessions as accounting records.
|
||||||
|
class StripeInvoiceService
|
||||||
|
attr_reader :order, :errors
|
||||||
|
|
||||||
|
def initialize(order)
|
||||||
|
@order = order
|
||||||
|
@errors = []
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a post-payment invoice in Stripe
|
||||||
|
#
|
||||||
|
# Returns the created Stripe invoice object or nil if creation failed
|
||||||
|
def create_post_payment_invoice
|
||||||
|
return nil unless valid_for_invoice_creation?
|
||||||
|
|
||||||
|
begin
|
||||||
|
customer = find_or_create_stripe_customer
|
||||||
|
return nil unless customer
|
||||||
|
|
||||||
|
invoice = create_stripe_invoice(customer)
|
||||||
|
return nil unless invoice
|
||||||
|
|
||||||
|
add_line_items_to_invoice(customer, invoice)
|
||||||
|
finalize_invoice(invoice)
|
||||||
|
|
||||||
|
Rails.logger.info "Successfully created Stripe invoice #{invoice.id} for order #{@order.id}"
|
||||||
|
invoice
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
handle_stripe_error(e)
|
||||||
|
nil
|
||||||
|
rescue => e
|
||||||
|
handle_generic_error(e)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the PDF URL for a Stripe invoice
|
||||||
|
#
|
||||||
|
# @param invoice_id [String] The Stripe invoice ID
|
||||||
|
# @return [String, nil] The invoice PDF URL or nil if not available
|
||||||
|
def self.get_invoice_pdf_url(invoice_id)
|
||||||
|
return nil if invoice_id.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
invoice = Stripe::Invoice.retrieve(invoice_id)
|
||||||
|
invoice.invoice_pdf
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
Rails.logger.error "Failed to retrieve Stripe invoice PDF URL: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def valid_for_invoice_creation?
|
||||||
|
unless @order.present?
|
||||||
|
@errors << "Order is required"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
unless @order.status == "paid"
|
||||||
|
@errors << "Order must be paid to create invoice"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
unless @order.user.present?
|
||||||
|
@errors << "Order must have an associated user"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
unless @order.tickets.any?
|
||||||
|
@errors << "Order must have tickets to create invoice"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_stripe_customer
|
||||||
|
if @order.user.stripe_customer_id.present?
|
||||||
|
retrieve_existing_customer
|
||||||
|
else
|
||||||
|
create_new_customer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def retrieve_existing_customer
|
||||||
|
Stripe::Customer.retrieve(@order.user.stripe_customer_id)
|
||||||
|
rescue Stripe::InvalidRequestError
|
||||||
|
# Customer doesn't exist, create a new one
|
||||||
|
Rails.logger.warn "Stripe customer #{@order.user.stripe_customer_id} not found, creating new customer"
|
||||||
|
@order.user.update(stripe_customer_id: nil)
|
||||||
|
create_new_customer
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_new_customer
|
||||||
|
customer = Stripe::Customer.create({
|
||||||
|
email: @order.user.email,
|
||||||
|
name: customer_name,
|
||||||
|
metadata: {
|
||||||
|
user_id: @order.user.id,
|
||||||
|
created_by: "#{ENV.fetch('INVOICE_COMPANY_NAME', 'aperonight').downcase}_system"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@order.user.update(stripe_customer_id: customer.id)
|
||||||
|
Rails.logger.info "Created new Stripe customer #{customer.id} for user #{@order.user.id}"
|
||||||
|
customer
|
||||||
|
end
|
||||||
|
|
||||||
|
def customer_name
|
||||||
|
parts = []
|
||||||
|
parts << @order.user.first_name if @order.user.first_name.present?
|
||||||
|
parts << @order.user.last_name if @order.user.last_name.present?
|
||||||
|
|
||||||
|
if parts.empty?
|
||||||
|
@order.user.email.split("@").first.humanize
|
||||||
|
else
|
||||||
|
parts.join(" ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_stripe_invoice(customer)
|
||||||
|
invoice_data = {
|
||||||
|
customer: customer.id,
|
||||||
|
collection_method: "send_invoice", # Don't auto-charge
|
||||||
|
auto_advance: false, # Don't automatically finalize
|
||||||
|
metadata: {
|
||||||
|
order_id: @order.id,
|
||||||
|
user_id: @order.user.id,
|
||||||
|
event_name: @order.event.name,
|
||||||
|
created_by: "#{ENV.fetch('INVOICE_COMPANY_NAME', 'aperonight').downcase}_system",
|
||||||
|
payment_method: "checkout_session"
|
||||||
|
},
|
||||||
|
description: "Invoice for #{@order.event.name} - Order ##{@order.id}",
|
||||||
|
footer: "Thank you for your purchase! This invoice is for your records as payment was already processed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add due date (same day since it's already paid)
|
||||||
|
invoice_data[:due_date] = Time.current.to_i
|
||||||
|
|
||||||
|
Stripe::Invoice.create(invoice_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_line_items_to_invoice(customer, invoice)
|
||||||
|
# Add ticket line items
|
||||||
|
@order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets|
|
||||||
|
quantity = tickets.count
|
||||||
|
|
||||||
|
Stripe::InvoiceItem.create({
|
||||||
|
customer: customer.id,
|
||||||
|
invoice: invoice.id,
|
||||||
|
amount: ticket_type.price_cents * quantity,
|
||||||
|
currency: "eur",
|
||||||
|
description: build_line_item_description(ticket_type, tickets),
|
||||||
|
metadata: {
|
||||||
|
ticket_type_id: ticket_type.id,
|
||||||
|
ticket_type_name: ticket_type.name,
|
||||||
|
quantity: quantity,
|
||||||
|
unit_price_cents: ticket_type.price_cents
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# No service fee on customer invoice; platform fee deducted from promoter payout
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_line_item_description(ticket_type, tickets)
|
||||||
|
quantity = tickets.count
|
||||||
|
unit_price = ticket_type.price_cents / 100.0
|
||||||
|
|
||||||
|
description_parts = [
|
||||||
|
"#{@order.event.name}",
|
||||||
|
"#{ticket_type.name}",
|
||||||
|
"(#{quantity}x €#{unit_price})"
|
||||||
|
]
|
||||||
|
|
||||||
|
description_parts.join(" - ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize_invoice(invoice)
|
||||||
|
# Mark as paid since payment was already processed via checkout
|
||||||
|
finalized_invoice = invoice.finalize_invoice
|
||||||
|
|
||||||
|
# Mark the invoice as paid
|
||||||
|
finalized_invoice.pay({
|
||||||
|
paid_out_of_band: true, # Payment was made outside of Stripe invoicing
|
||||||
|
payment_method: nil # No payment method needed for out-of-band payment
|
||||||
|
})
|
||||||
|
|
||||||
|
finalized_invoice
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_stripe_error(error)
|
||||||
|
error_message = "Stripe invoice creation failed: #{error.message}"
|
||||||
|
@errors << error_message
|
||||||
|
Rails.logger.error "#{error_message} (Order: #{@order.id})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_generic_error(error)
|
||||||
|
error_message = "Invoice creation failed: #{error.message}"
|
||||||
|
@errors << error_message
|
||||||
|
Rails.logger.error "#{error_message} (Order: #{@order.id})"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
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)
|
||||||
@@ -10,46 +16,59 @@ class TicketPdfGenerator
|
|||||||
end
|
end
|
||||||
|
|
||||||
def generate
|
def generate
|
||||||
Prawn::Document.new(page_size: [350, 600], margin: 20) do |pdf|
|
Prawn::Document.new(page_size: [ 350, 600 ], margin: 20) do |pdf|
|
||||||
# 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 "ApéroNight", align: :center
|
pdf.text ENV.fetch("APP_NAME", "Aperonight"), 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 20
|
pdf.move_down 10
|
||||||
|
|
||||||
# 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, 120, 10
|
pdf.rounded_rectangle [ 0, pdf.cursor ], 310, 150, 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.text "Ticket Type:", style: :bold
|
pdf.indent 10 do
|
||||||
|
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.text "Price:", style: :bold
|
pdf.indent 10 do
|
||||||
pdf.text "€#{ticket.price_euros}"
|
pdf.text "Prix :", style: :bold
|
||||||
|
pdf.text "#{ticket.price_euros} €"
|
||||||
|
end
|
||||||
pdf.move_down 8
|
pdf.move_down 8
|
||||||
|
|
||||||
pdf.text "Date & Time:", style: :bold
|
pdf.indent 10 do
|
||||||
pdf.text ticket.event.start_time.strftime("%B %d, %Y at %I:%M %p")
|
pdf.text "Date et heure :", style: :bold
|
||||||
|
pdf.text ticket.event.start_time.strftime("%d %B %Y à %H:%M")
|
||||||
|
end
|
||||||
pdf.move_down 20
|
pdf.move_down 20
|
||||||
|
|
||||||
# Venue information
|
# Informations sur le lieu
|
||||||
pdf.fill_color "374151"
|
pdf.fill_color "374151"
|
||||||
pdf.font "Helvetica", style: :bold, size: 14
|
pdf.font "Helvetica", style: :bold, size: 14
|
||||||
pdf.text "Venue Information"
|
pdf.text "Informations sur le lieu"
|
||||||
pdf.move_down 8
|
pdf.move_down 8
|
||||||
|
|
||||||
pdf.font "Helvetica", size: 11
|
pdf.font "Helvetica", size: 11
|
||||||
@@ -57,10 +76,10 @@ class TicketPdfGenerator
|
|||||||
pdf.text ticket.event.venue_address
|
pdf.text ticket.event.venue_address
|
||||||
pdf.move_down 20
|
pdf.move_down 20
|
||||||
|
|
||||||
# QR Code
|
# Code QR
|
||||||
pdf.fill_color "000000"
|
pdf.fill_color "000000"
|
||||||
pdf.font "Helvetica", style: :bold, size: 14
|
pdf.font "Helvetica", style: :bold, size: 14
|
||||||
pdf.text "Ticket QR Code", align: :center
|
pdf.text "Code QR", align: :center
|
||||||
pdf.move_down 10
|
pdf.move_down 10
|
||||||
|
|
||||||
# Ensure all required data is present before generating QR code
|
# Ensure all required data is present before generating QR code
|
||||||
@@ -68,40 +87,211 @@ class TicketPdfGenerator
|
|||||||
raise "Ticket QR code is missing"
|
raise "Ticket QR code is missing"
|
||||||
end
|
end
|
||||||
|
|
||||||
qr_code_data = {
|
# Build QR code data with safe association loading
|
||||||
ticket_id: ticket.id,
|
qr_code_data = build_qr_code_data(ticket)
|
||||||
qr_code: ticket.qr_code,
|
|
||||||
event_id: ticket.event&.id,
|
|
||||||
user_id: ticket.user&.id
|
|
||||||
}.compact.to_json
|
|
||||||
|
|
||||||
# Validate QR code data before creating QR code
|
# Validate QR code data before creating QR code
|
||||||
if qr_code_data.blank? || qr_code_data == "{}"
|
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"
|
raise "QR code data is empty or invalid"
|
||||||
end
|
end
|
||||||
|
|
||||||
qrcode = RQRCode::QRCode.new(qr_code_data)
|
# Ensure qr_code_data is a proper string for QR code generation
|
||||||
pdf.print_qr_code(qrcode, extent: 120, align: :center)
|
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 "QR Code: #{ticket.qr_code[0..7]}...", align: :center
|
pdf.text "#{ticket.qr_code}", align: :center
|
||||||
|
|
||||||
|
|
||||||
|
# Ticket ID
|
||||||
|
pdf.font "Helvetica", size: 8
|
||||||
|
pdf.fill_color "6B7280"
|
||||||
|
pdf.text "ID du billet : #{ticket.id}", align: :center
|
||||||
|
|
||||||
# Footer
|
# 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 10
|
pdf.move_down 6
|
||||||
|
|
||||||
pdf.font "Helvetica", size: 8
|
pdf.font "Helvetica", size: 8
|
||||||
pdf.fill_color "6B7280"
|
pdf.fill_color "6B7280"
|
||||||
pdf.text "This ticket is valid for one entry only.", align: :center
|
pdf.text "Ce billet est valable pour une seule entrée.", align: :center
|
||||||
pdf.text "Present this ticket at the venue entrance.", align: :center
|
pdf.text "Présentez ce billet à l'entrée du lieu.", align: :center
|
||||||
pdf.move_down 5
|
pdf.move_down 5
|
||||||
pdf.text "Generated on #{Time.current.strftime('%B %d, %Y at %I:%M %p')}", align: :center
|
pdf.text "Généré le #{Time.current.strftime('%d %B %Y à %H:%M')}", align: :center
|
||||||
end.render
|
end.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
|
||||||
2
app/views/admin/payouts/create.html.erb
Normal file
2
app/views/admin/payouts/create.html.erb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<h1>Admin::Payouts#create</h1>
|
||||||
|
<p>Find me in app/views/admin/payouts/create.html.erb</p>
|
||||||
78
app/views/admin/payouts/index.html.erb
Normal file
78
app/views/admin/payouts/index.html.erb
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Admin Payouts</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @payouts.any? %>
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Promoter</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<% @payouts.each do |payout| %>
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900"><%= payout.event.name %></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-900"><%= payout.user.name.presence || payout.user.email %></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
|
||||||
|
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_euros %>)</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<% case payout.status %>
|
||||||
|
<% when 'pending' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
<% when 'processing' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||||
|
Processing
|
||||||
|
</span>
|
||||||
|
<% when 'completed' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
<% when 'failed' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<%= payout.created_at.strftime("%b %d, %Y") %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<% if payout.can_process? %>
|
||||||
|
<%= button_to "Process", admin_payout_path(payout), method: :post,
|
||||||
|
class: "text-indigo-600 hover:text-indigo-900 bg-indigo-100 hover:bg-indigo-200 px-3 py-1 rounded" %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "View", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 ml-2" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @payouts.respond_to?(:total_pages) %>
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= paginate @payouts %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 text-center">
|
||||||
|
<p class="text-gray-500">No payouts found.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
2
app/views/admin/payouts/new.html.erb
Normal file
2
app/views/admin/payouts/new.html.erb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<h1>Admin::Payouts#new</h1>
|
||||||
|
<p>Find me in app/views/admin/payouts/new.html.erb</p>
|
||||||
2
app/views/admin/payouts/show.html.erb
Normal file
2
app/views/admin/payouts/show.html.erb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<h1>Admin::Payouts#show</h1>
|
||||||
|
<p>Find me in app/views/admin/payouts/show.html.erb</p>
|
||||||
46
app/views/components/_breadcrumb.html.erb
Normal file
46
app/views/components/_breadcrumb.html.erb
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<%# Dynamic breadcrumb navigation component %>
|
||||||
|
<%# Usage: render 'components/breadcrumb', crumbs: [ %>
|
||||||
|
<%# { name: 'Home', path: root_path }, %>
|
||||||
|
<%# { name: 'Events', path: events_path }, %>
|
||||||
|
<%# { name: 'Current Event', path: nil } %>
|
||||||
|
<%# ] %>
|
||||||
|
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="w-full bg-white px-3 sm:px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-6 sm:mb-8 overflow-hidden" aria-label="Breadcrumb">
|
||||||
|
<div class="flex items-center gap-1 sm:gap-2 min-w-0">
|
||||||
|
<% crumbs.each_with_index do |crumb, index| %>
|
||||||
|
<% if crumb[:path].present? %>
|
||||||
|
<%# Crumb with link %>
|
||||||
|
<%= link_to crumb[:path], class: "inline-flex items-center text-xs sm:text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors duration-200 flex-shrink-0" do %>
|
||||||
|
<% if index == 0 %>
|
||||||
|
<i data-lucide="home" class="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0"></i>
|
||||||
|
<% end %>
|
||||||
|
<span class="<%= 'hidden sm:inline' if index > 0 && index < crumbs.length - 2 %>">
|
||||||
|
<%= crumb[:name] %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<%# Current page (no link) %>
|
||||||
|
<span class="text-xs sm:text-sm font-medium text-primary-600 truncate min-w-0 flex-1" aria-current="page">
|
||||||
|
<%= crumb[:name] %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Separator (except for the last item) %>
|
||||||
|
<% if index < crumbs.length - 1 %>
|
||||||
|
<% if index == 0 || index >= crumbs.length - 2 %>
|
||||||
|
<i data-lucide="chevron-right" class="w-3 h-3 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0"></i>
|
||||||
|
<% else %>
|
||||||
|
<span class="hidden sm:inline">
|
||||||
|
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400 flex-shrink-0"></i>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Show ellipsis on mobile when there are more than 3 items %>
|
||||||
|
<% if crumbs.length > 3 %>
|
||||||
|
<span class="text-gray-400 text-xs font-medium sm:hidden flex-shrink-0">...</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
17
app/views/components/_delete_account.html.erb
Normal file
17
app/views/components/_delete_account.html.erb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- Delete Account Section -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">Supprimer mon compte</h3>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
Vous êtes certain de vouloir supprimer votre compte ? Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
<%= button_to registration_path(resource_name),
|
||||||
|
data: {
|
||||||
|
confirm: "Êtes-vous certain ?",
|
||||||
|
turbo_confirm: "Êtes-vous certain ?"
|
||||||
|
},
|
||||||
|
method: :delete,
|
||||||
|
class: "group relative w-full flex justify-center items-center py-3 px-4 border border-red-300 text-sm font-semibold rounded-xl text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200" do %>
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||||
|
Supprimer mon compte
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="event-finder">
|
<div class="event-finder">
|
||||||
<div class="finder-header">
|
<div class="finder-header">
|
||||||
<h2 class="finder-title">Find Your Perfect Event</h2>
|
<h2 class="finder-title">Trouvez votre événement parfait</h2>
|
||||||
<p class="finder-subtitle">Discover afterwork events tailored to your preferences</p>
|
<p class="finder-subtitle">Découvrez des événements afterwork adaptés à vos préférences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="finder-form">
|
<form class="finder-form">
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
<div class="finder-field">
|
<div class="finder-field">
|
||||||
<label class="finder-label">
|
<label class="finder-label">
|
||||||
<i data-lucide="map-pin"></i>
|
<i data-lucide="map-pin"></i>
|
||||||
City
|
Ville
|
||||||
</label>
|
</label>
|
||||||
<select class="finder-select focus-ring" id="event-city">
|
<select class="finder-select focus-ring" id="event-city">
|
||||||
<option value="">Choose a city</option>
|
<option value="">Choisissez une ville</option>
|
||||||
<option value="paris">Paris</option>
|
<option value="paris">Paris</option>
|
||||||
<option value="london">London</option>
|
<option value="london">London</option>
|
||||||
<option value="berlin">Berlin</option>
|
<option value="berlin">Berlin</option>
|
||||||
@@ -37,18 +37,18 @@
|
|||||||
<div class="finder-field">
|
<div class="finder-field">
|
||||||
<label class="finder-label">
|
<label class="finder-label">
|
||||||
<i data-lucide="users"></i>
|
<i data-lucide="users"></i>
|
||||||
Event Type
|
Type d'événement
|
||||||
</label>
|
</label>
|
||||||
<select class="finder-select focus-ring" id="event-type">
|
<select class="finder-select focus-ring" id="event-type">
|
||||||
<option value="">All types</option>
|
<option value="">Tous les types</option>
|
||||||
<option value="networking">Networking</option>
|
<option value="networking">Réseautage</option>
|
||||||
<option value="tech">Tech & Innovation</option>
|
<option value="tech">Tech & Innovation</option>
|
||||||
<option value="creative">Creative & Design</option>
|
<option value="creative">Créatif & Design</option>
|
||||||
<option value="business">Business</option>
|
<option value="business">Affaires</option>
|
||||||
<option value="startup">Startup</option>
|
<option value="startup">Startup</option>
|
||||||
<option value="wine">Wine & Tasting</option>
|
<option value="wine">Vin & Dégustation</option>
|
||||||
<option value="art">Art & Culture</option>
|
<option value="art">Art & Culture</option>
|
||||||
<option value="music">Music & Entertainment</option>
|
<option value="music">Musique & Divertissement</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,14 +58,14 @@
|
|||||||
<div class="price-range-label">
|
<div class="price-range-label">
|
||||||
<span>
|
<span>
|
||||||
<i data-lucide="euro"></i>
|
<i data-lucide="euro"></i>
|
||||||
Price Range
|
Fourchette de prix
|
||||||
</span>
|
</span>
|
||||||
<span class="price-value" id="price-display">€0 - €100</span>
|
<span class="price-value" id="price-display">€0 - €100</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div style="display: flex; gap: var(--space-3); align-items: center;">
|
<div style="display: flex; gap: var(--space-3); align-items: center;">
|
||||||
<input type="range" class="price-slider" id="price-min" min="0" max="100" value="0" style="flex: 1;">
|
<input type="range" class="price-slider" id="price-min" min="0" max="100" value="0" style="flex: 1;">
|
||||||
<span style="color: var(--color-neutral-500); font-weight: 600;">to</span>
|
<span style="color: var(--color-neutral-500); font-weight: 600;">à</span>
|
||||||
<input type="range" class="price-slider" id="price-max" min="0" max="100" value="100" style="flex: 1;">
|
<input type="range" class="price-slider" id="price-max" min="0" max="100" value="100" style="flex: 1;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
<button type="submit" class="finder-search-btn">
|
<button type="submit" class="finder-search-btn">
|
||||||
<i data-lucide="search"></i>
|
<i data-lucide="search"></i>
|
||||||
Find Events
|
Trouver des événements
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Event Finder Functionality
|
// Fonctionnalité de recherche d'événements
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
const priceMin = document.getElementById('price-min');
|
const priceMin = document.getElementById('price-min');
|
||||||
const priceMax = document.getElementById('price-max');
|
const priceMax = document.getElementById('price-max');
|
||||||
@@ -134,18 +134,18 @@
|
|||||||
priceMax: priceMax ? priceMax.value : ''
|
priceMax: priceMax ? priceMax.value : ''
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Search filters:', formData);
|
console.log('Filtres de recherche :', formData);
|
||||||
|
|
||||||
// Add loading state to button
|
// Add loading state to button
|
||||||
const searchBtn = document.querySelector('.finder-search-btn');
|
const searchBtn = document.querySelector('.finder-search-btn');
|
||||||
if (searchBtn) {
|
if (searchBtn) {
|
||||||
const originalText = searchBtn.innerHTML;
|
const originalText = searchBtn.innerHTML;
|
||||||
searchBtn.innerHTML = '<div style="width: 20px; height: 20px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div> Searching...';
|
searchBtn.innerHTML = '<div style="width: 20px; height: 20px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div> Recherche...';
|
||||||
|
|
||||||
// Simulate search
|
// Simulate search
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
searchBtn.innerHTML = originalText;
|
searchBtn.innerHTML = originalText;
|
||||||
alert('Search completed! Results would be displayed here.');
|
alert('Recherche terminée ! Les résultats seraient affichés ici.');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,92 @@
|
|||||||
<div class="grid gap-6 mb-6 md:grid-cols-2 lg:grid-cols-4">
|
<div class="grid gap-8 mb-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg text-white mb-3">À propos</h3>
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
<ul class="space-y-2">
|
<i data-lucide="info" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Je suis organisateur</a></li>
|
À propos
|
||||||
<%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Pour les artistes</a></li> %>
|
</h3>
|
||||||
<%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Kit presse</a></li> %>
|
<ul class="space-y-3">
|
||||||
<%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Artistes</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="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>
|
<div>
|
||||||
<h3 class="font-bold text-lg text-white mb-3">Villes</h3>
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
<ul class="space-y-2">
|
<i data-lucide="map-pin" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Paris</a></li>
|
Villes
|
||||||
<%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Lyon</a></li> %>
|
</h3>
|
||||||
<%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Marseille / Aix-en-Provence</a></li> %>
|
<ul class="space-y-3">
|
||||||
<%# <li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Toulouse</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>
|
|
||||||
<h3 class="font-bold text-lg text-white mb-3">Organisateurs</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Help Center</a></li>
|
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Contact Us</a></li>
|
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Safety Guidelines</a></li>
|
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Cancellation Policy</a></li>
|
|
||||||
</ul>
|
|
||||||
</div> %>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg text-white mb-3">Support</h3>
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
<ul class="space-y-2">
|
<i data-lucide="calendar" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Aide</a></li>
|
Événements
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Nous contacter</a></li>
|
</h3>
|
||||||
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Signaler un contenu inapproprié</a></li>
|
<ul class="space-y-3">
|
||||||
|
<li><a href="<%= events_path %>" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
|
||||||
|
<i data-lucide="glass-water" class="w-4 h-4 mr-2"></i>
|
||||||
|
Afterworks
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
|
||||||
|
<i data-lucide="music" class="w-4 h-4 mr-2"></i>
|
||||||
|
Concerts
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
|
<i data-lucide="help-circle" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
|
Support
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
|
||||||
|
<i data-lucide="life-buoy" class="w-4 h-4 mr-2"></i>
|
||||||
|
Aide
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
|
||||||
|
<i data-lucide="mail" class="w-4 h-4 mr-2"></i>
|
||||||
|
Nous contacter
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
|
||||||
|
<i data-lucide="flag" class="w-4 h-4 mr-2"></i>
|
||||||
|
Signaler un contenu
|
||||||
|
</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-neutral-700 pt-4 text-center text-neutral-400 text-sm">
|
|
||||||
<p>© 2025 Aperonight. All rights reserved. • <a href="#" class="text-accent-400 hover:text-accent-300 transition-colors">Privacy Policy</a> • <a href="#" class="text-accent-400 hover:text-accent-300 transition-colors">Terms of Service</a></p>
|
<div class="border-t border-gray-700 pt-6 text-center">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-center">
|
||||||
|
<p class="text-gray-400 text-sm mb-4 sm:mb-0">
|
||||||
|
© 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>
|
||||||
@@ -1,22 +1,40 @@
|
|||||||
<header class="bg-neutral-800 border-b border-neutral-700">
|
<header class="bg-white shadow-lg border-b border-gray-200 sticky top-0 z-50">
|
||||||
<nav data-controller="header" class="container mx-auto px-4 sm:px-6 lg:px-8">
|
<nav data-controller="header" class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center h-16 justify-between">
|
<div class="flex items-center h-16 justify-between">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex-shrink-0 flex items-center">
|
<div class="flex-shrink-0 flex items-center">
|
||||||
<%= link_to Rails.application.config.app_name, current_user ? "/dashboard" : "/",
|
<%= link_to Rails.application.config.app_name, "/",
|
||||||
class: "text-xl font-bold text-white" %>
|
class: "text-2xl font-display font-bold text-gray-900 hover:text-brand-primary transition-colors" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
<div class="hidden sm:flex items-center space-x-6 w-full justify-start">
|
<div class="hidden sm:flex items-center space-x-8 w-full justify-center">
|
||||||
<%= link_to "Afterworks", events_path,
|
<%= link_to events_path,
|
||||||
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
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 "Évenements", "#",
|
<%= link_to dashboard_path,
|
||||||
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
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 %>
|
||||||
|
|
||||||
<%= link_to "Concerts", "#",
|
<% if user_signed_in? && current_user.promoter? %>
|
||||||
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
<%= 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 -->
|
||||||
@@ -24,82 +42,147 @@
|
|||||||
<% if user_signed_in? %>
|
<% if user_signed_in? %>
|
||||||
<div class="relative" data-header-target="userMenuButton">
|
<div class="relative" data-header-target="userMenuButton">
|
||||||
<button data-action="click->header#toggleUserMenu"
|
<button data-action="click->header#toggleUserMenu"
|
||||||
class="bg-purple-700 text-white border border-purple-800 font-medium py-2 px-4 rounded-lg hover:bg-purple-800 transition-colors duration-200 flex items-center space-x-2">
|
class="bg-gray-900 text-white font-medium py-2 px-4 rounded-full hover:bg-gray-800 transition-colors duration-200 flex items-center space-x-2">
|
||||||
<span><%= current_user.email.length > 20 ? current_user.email[0,20] + "..." : current_user.email %></span>
|
<span><%= current_user.email.length > 20 ? current_user.email[0,20] + "..." : current_user.email %></span>
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<i data-lucide="chevron-down" class="w-4 h-4"></i>
|
||||||
<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 -->
|
<!-- User Dropdown Menu -->
|
||||||
<div data-header-target="userMenu" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
<div data-header-target="userMenu" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-2xl shadow-xl border border-gray-100 py-2 z-50">
|
||||||
<div class="px-4 py-2 text-sm text-gray-900 border-b border-gray-100">
|
<div class="px-4 py-3 text-sm text-gray-900 border-b border-gray-100">
|
||||||
<div class="font-medium"><%= current_user.first_name || current_user.email %></div>
|
<div class="font-semibold"><%= current_user.first_name || current_user.email %></div>
|
||||||
<div class="text-gray-500"><%= current_user.email %></div>
|
<div class="text-gray-500"><%= current_user.email %></div>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to "Profile", edit_user_registration_path,
|
<%= link_to "#",
|
||||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-200" %>
|
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
|
||||||
<%= link_to "Reservations", "#",
|
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
|
||||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-200" %>
|
Réservations
|
||||||
|
<% end %>
|
||||||
|
<%= link_to settings_path,
|
||||||
|
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
|
||||||
|
<i data-lucide="user" class="w-4 h-4 mr-3"></i>
|
||||||
|
Profil
|
||||||
|
<% end %>
|
||||||
|
<%= link_to edit_user_registration_path,
|
||||||
|
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
|
||||||
|
<i data-lucide="key" class="w-4 h-4 mr-3"></i>
|
||||||
|
Sécurité
|
||||||
|
<% end %>
|
||||||
<div class="border-t border-gray-100">
|
<div class="border-t border-gray-100">
|
||||||
<%= link_to "Sign out", destroy_user_session_path,
|
<%= 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: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-200" %>
|
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>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to t("header.login"), new_user_session_path,
|
<%= link_to new_user_session_path,
|
||||||
class: "text-gray-100 hover:text-purple-200 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
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.register"), new_user_registration_path,
|
Se connecter
|
||||||
class: "bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
|
<% end %>
|
||||||
|
<%= link_to new_user_registration_path,
|
||||||
|
class: "bg-gray-900 text-white font-semibold py-2 px-8 rounded-full hover:bg-gray-800 transition-colors duration-200 shadow-lg hover:shadow-xl whitespace-nowrap" do %>
|
||||||
|
S'inscrire
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<div class="flex-shrink-0 sm:hidden">
|
<div class="flex-shrink-0 sm:hidden">
|
||||||
<button data-action="click->header#toggleMobileMenu" data-header-target="mobileMenuButton" class="p-2 rounded-md text-neutral-300 hover:text-white hover:bg-purple-700">
|
<button data-action="click->header#toggleMobileMenu" data-header-target="mobileMenuButton" class="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100">
|
||||||
<svg data-menu-icon="open" 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 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>
|
||||||
</svg>
|
|
||||||
<svg data-menu-icon="close" class="h-6 w-6 hidden" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
<!-- Mobile Menu -->
|
||||||
<div data-header-target="mobileMenu" class="hidden sm:hidden">
|
<div data-header-target="mobileMenu" class="hidden sm:hidden border-t border-gray-200">
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
<div class="px-4 pt-4 pb-3 space-y-2">
|
||||||
<%= link_to t("header.parties"), events_path,
|
<%= link_to events_path,
|
||||||
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
|
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 %>
|
||||||
<%= link_to t("header.concerts"), "#",
|
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
|
||||||
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
|
Événements & Afterworks
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to dashboard_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="bar-chart-3" class="w-4 h-4 mr-3"></i>
|
||||||
|
Tableau de bord
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if user_signed_in? && current_user.promoter? %>
|
||||||
|
<%= link_to new_promoter_event_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="plus-circle" class="w-4 h-4 mr-3"></i>
|
||||||
|
Créer un événement
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to promoter_events_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="calendar-check" class="w-4 h-4 mr-3"></i>
|
||||||
|
Mes événements
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- <%= link_to events_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="glass-water" class="w-4 h-4 mr-3"></i>
|
||||||
|
Afterworks
|
||||||
|
<% end %> -->
|
||||||
|
<!-- <%= link_to "#",
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="music" class="w-4 h-4 mr-3"></i>
|
||||||
|
Concerts
|
||||||
|
<% end %> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-4 pb-3 border-t border-gray-700">
|
<div class="pt-4 pb-4 border-t border-gray-200">
|
||||||
<% if user_signed_in? %>
|
<% if user_signed_in? %>
|
||||||
<div class="px-4 mb-3">
|
<div class="px-4 mb-3">
|
||||||
<div class="text-base font-medium text-white">
|
<div class="text-base font-semibold text-gray-900">
|
||||||
<%= current_user.first_name || current_user.email %>
|
<%= 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-2 space-y-1">
|
<div class="px-4 space-y-2">
|
||||||
<%= link_to t("header.profile"), edit_user_registration_path,
|
<%= link_to "#",
|
||||||
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
<%= link_to t("header.reservations"), "#",
|
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
|
||||||
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
|
Réservations
|
||||||
<%= link_to t("header.logout"), destroy_user_session_path,
|
<% end %>
|
||||||
|
<%= link_to settings_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="user" class="w-4 h-4 mr-3"></i>
|
||||||
|
Profil
|
||||||
|
<% end %>
|
||||||
|
<%= link_to edit_user_registration_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="key" class="w-4 h-4 mr-3"></i>
|
||||||
|
Sécurité
|
||||||
|
<% end %>
|
||||||
|
<%= link_to destroy_user_session_path,
|
||||||
data: { controller: "logout", action: "click->logout#signOut", logout_url_value: destroy_user_session_path, login_url_value: new_user_session_path, turbo: false },
|
data: { controller: "logout", action: "click->logout#signOut", logout_url_value: destroy_user_session_path, login_url_value: new_user_session_path, turbo: false },
|
||||||
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="log-out" class="w-4 h-4 mr-3"></i>
|
||||||
|
Déconnexion
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="px-2 space-y-1">
|
<div class="px-4 space-y-2">
|
||||||
<%= link_to t("header.login"), new_user_session_path,
|
<%= link_to new_user_session_path,
|
||||||
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
|
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 %>
|
||||||
<%= link_to t("header.register"), new_user_registration_path,
|
<i data-lucide="log-in" class="w-4 h-4 mr-3"></i>
|
||||||
class: "block px-3 py-2 rounded-md text-base font-medium bg-purple-600 text-white hover:bg-purple-700" %>
|
Se connecter
|
||||||
|
<% end %>
|
||||||
|
<%= link_to new_user_registration_path,
|
||||||
|
class: "flex items-center justify-center px-3 py-2 rounded-lg text-base font-semibold bg-gray-900 text-white hover:bg-gray-800" do %>
|
||||||
|
<i data-lucide="user-plus" class="w-4 h-4 mr-3"></i>
|
||||||
|
S'inscrire
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>">
|
<p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>">
|
||||||
<%= number_to_currency(price_cents / 100.0, unit: "€") %>
|
<%= format_ticket_price(price_cents) %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,17 +16,13 @@
|
|||||||
<div class="<%= 'order-2 sm:order-1' unless sold_out %>">
|
<div class="<%= 'order-2 sm:order-1' unless sold_out %>">
|
||||||
<% 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">
|
||||||
<svg class="-ml-0.5 mr-1 h-2 w-2 text-red-400" fill="currentColor" viewBox="0 0 8 8">
|
<i data-lucide="x" class="w-2 h-2 mr-1 text-red-400"></i>
|
||||||
<circle cx="4" cy="4" r="3" />
|
|
||||||
</svg>
|
|
||||||
Épuisé
|
Épuisé
|
||||||
</span>
|
</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
<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">
|
||||||
<svg class="-ml-0.5 mr-1 h-2 w-2 text-green-400" fill="currentColor" viewBox="0 0 8 8">
|
<i data-lucide="ticket" class="w-3 h-3 mr-1 text-green-500"></i>
|
||||||
<circle cx="4" cy="4" r="3" />
|
<%= remaining %>
|
||||||
</svg>
|
|
||||||
<%= remaining %> disponibles
|
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,9 +55,7 @@
|
|||||||
</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 order-1 sm:order-2">
|
||||||
<svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<i data-lucide="lock" class="w-5 h-5 inline-block mr-1"></i>
|
||||||
<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 %>
|
||||||
|
|||||||
@@ -1,34 +1,52 @@
|
|||||||
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="min-h-screen max-w-md w-full space-y-8">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div>
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
<%= link_to "/" do %>
|
<%= link_to "/" do %>
|
||||||
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
|
||||||
|
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
|
<h2 class="text-3xl font-bold text-gray-900"><%= t('devise.confirmations.new.title') %></h2>
|
||||||
<%= t('devise.confirmations.new.title') %>
|
<p class="mt-2 text-gray-600">
|
||||||
</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_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: "mt-8 space-y-6" }) do |f| %>
|
<!-- Form -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: "space-y-6" }) do |f| %>
|
||||||
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %>
|
<%= f.label :email, class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
<div class="mt-1">
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email),
|
<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email),
|
||||||
class: "appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm",
|
placeholder: "votre@email.com",
|
||||||
placeholder: "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" %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="pt-4">
|
||||||
<%= f.submit t('devise.confirmations.new.submit'),
|
<%= 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 %>
|
||||||
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="send" class="w-4 h-4 mr-2"></i>
|
||||||
|
<%= t('devise.confirmations.new.submit') %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
<!-- Additional Links -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous vous souvenez de votre mot de passe ?
|
||||||
|
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,43 +1,69 @@
|
|||||||
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="min-h-screen max-w-md w-full space-y-8">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div>
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
<%= link_to "/" do %>
|
<%= link_to "/" do %>
|
||||||
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
|
||||||
|
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
|
<h2 class="text-3xl font-bold text-gray-900"><%= t('devise.passwords.edit.title') %></h2>
|
||||||
<%= t('devise.passwords.edit.title') %>
|
<p class="mt-2 text-gray-600">
|
||||||
</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_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: "mt-8 space-y-6" }) do |f| %>
|
<!-- Form -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: "space-y-6" }) do |f| %>
|
||||||
<%= f.hidden_field :reset_password_token %>
|
<%= f.hidden_field :reset_password_token %>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<%= f.label :password, t('devise.passwords.edit.new_password'), class: "block text-sm font-medium text-neutral-700" %>
|
<%= f.label :password, t('devise.passwords.edit.new_password'), class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
<% if @minimum_password_length %>
|
<% if @minimum_password_length %>
|
||||||
<em class="text-sm text-neutral-500">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</em>
|
<p class="text-xs text-gray-500 mb-2">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</p>
|
||||||
<% end %>
|
<% 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",
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
placeholder: "Votre nouveau mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= f.label :password_confirmation, t('devise.passwords.edit.confirm_new_password'), class: "block text-sm font-medium text-neutral-700" %>
|
<%= f.label :password_confirmation, t('devise.passwords.edit.confirm_new_password'), class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
placeholder: "Confirmez votre nouveau mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="pt-4">
|
||||||
<%= f.submit t('devise.passwords.edit.submit'),
|
<%= 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 %>
|
||||||
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="refresh-ccw" class="w-4 h-4 mr-2"></i>
|
||||||
|
<%= t('devise.passwords.edit.submit') %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
<!-- Additional Links -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous vous souvenez de votre mot de passe ?
|
||||||
|
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,45 +1,51 @@
|
|||||||
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="min-h-screen max-w-md w-full space-y-8">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div>
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
<%= link_to "/" do %>
|
<%= link_to "/" do %>
|
||||||
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
|
||||||
|
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
|
<h2 class="text-3xl font-bold text-gray-900">Mot de passe oublié ?</h2>
|
||||||
Mot de passe oublié ?
|
<p class="mt-2 text-gray-600">
|
||||||
</h2>
|
|
||||||
<p class="mt-2 text-center text-sm text-neutral-600">
|
|
||||||
Entrez votre adresse email ci-dessous et nous vous enverrons un lien pour réinitialiser votre mot de passe.
|
Entrez votre adresse email ci-dessous et nous vous enverrons un lien pour réinitialiser votre mot de passe.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: "mt-8 space-y-6" }) do |f| %>
|
<!-- Form -->
|
||||||
|
<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-medium text-neutral-700" %>
|
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
<div class="mt-1">
|
<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: "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: "votre@email.com",
|
||||||
placeholder: t('devise.passwords.new.email_placeholder') %>
|
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>
|
<div class="pt-4">
|
||||||
<%= f.submit "Envoyer le lien de réinitialisation",
|
<%= 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 %>
|
||||||
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="send" class="w-4 h-4 mr-2"></i>
|
||||||
|
Envoyer le lien de réinitialisation
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-6">
|
<!-- Additional Links -->
|
||||||
<div class="relative">
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
<div class="absolute inset-0 flex items-center">
|
<div class="text-center">
|
||||||
<div class="w-full border-t border-neutral-300"></div>
|
<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 class="relative flex justify-center text-sm">
|
|
||||||
<span class="px-2 bg-neutral-50 text-neutral-600">Continuer avec</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user