Compare commits
163 Commits
394190ace8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 580b24bbed | |||
| 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 | ||
|
|
a3689948ae | ||
|
|
d18c1a7b3e | ||
|
|
a0e53325f7 | ||
|
|
61079c8171 | ||
|
|
e1edc1afcd | ||
|
|
bd6c0d5ed8 | ||
|
|
5fc790cd42 | ||
|
|
ec5095d372 | ||
|
|
31f5d2188d | ||
|
|
e866e259bb | ||
|
|
54e99c2f7e | ||
|
|
3ba5710d8f | ||
|
|
0f6d75b1e8 | ||
|
|
ee4399aa46 | ||
|
|
839120f2f4 | ||
|
|
6965eb89fd | ||
|
|
0ba6634e99 | ||
|
|
ca81d2360c | ||
|
|
afe074c8a1 | ||
|
|
e838e91162 | ||
|
|
aa5dccb508 | ||
|
|
3414057795 | ||
|
|
1acc3e09d4 | ||
|
|
48c648e2ca | ||
|
|
b493027c86 | ||
|
|
6ea3005a65 | ||
|
|
476438c5c4 | ||
|
|
055640b73e | ||
|
|
a7e83d79d7 | ||
|
|
9404f10c93 | ||
|
|
907e51fc60 | ||
|
|
56b0a45719 | ||
|
|
58dbcf3a6a |
32
.env.example
32
.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,20 +28,24 @@ 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
|
||||||
|
|
||||||
# Application variables
|
# Invoice Emitter Configuration
|
||||||
STRIPE_API_KEY=1337
|
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=
|
||||||
|
|
||||||
# OpenAI login
|
# Application variables
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_51S1M7BJWx6G2LLIXYpTvi0hxMpZ4tZSxkmr2Wbp1dQ73MKNp4Tyu4xFJBqLXK5nn4E0nEf2tdgJqEwWZLosO3QGn00kMvjXWGW
|
||||||
|
STRIPE_SECRET_KEY=sk_test_51S1M7BJWx6G2LLIXK2pdLpRKb9Mgd3sZ30N4ueVjHepgxQKbWgMVJoa4v4ESzHQ6u6zJjO4jUvgLYPU1QLyAiFTN00sGz2ortW
|
||||||
|
STRIPE_WEBHOOK_SECRET=LaReunion974
|
||||||
|
|
||||||
|
# Scaleway login
|
||||||
OPENAI_API_KEY=f66dbb5f-9770-4f81-b2ea-eb7370bc9aa5
|
OPENAI_API_KEY=f66dbb5f-9770-4f81-b2ea-eb7370bc9aa5
|
||||||
OPENAI_BASE_URL=https://api.scaleway.ai/v1
|
OPENAI_BASE_URL=https://api.scaleway.ai/v1
|
||||||
OPENAI_MODEL=devstral-small-2505
|
OPENAI_MODEL=devstral-small-2505
|
||||||
|
|||||||
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,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,618 +0,0 @@
|
|||||||
/* Enhanced Aperonight Design System - Event Booking Optimized */
|
|
||||||
:root {
|
|
||||||
/* Enhanced Purple gradient system with more depth */
|
|
||||||
--color-primary-50: #faf5ff;
|
|
||||||
--color-primary-100: #f3e8ff;
|
|
||||||
--color-primary-200: #e9d5ff;
|
|
||||||
--color-primary-300: #d8b4fe;
|
|
||||||
--color-primary-400: #c084fc;
|
|
||||||
--color-primary-500: #a855f7;
|
|
||||||
--color-primary-600: #9333ea;
|
|
||||||
--color-primary-700: #7e22ce;
|
|
||||||
--color-primary-800: #6b21a8;
|
|
||||||
--color-primary-900: #581c87;
|
|
||||||
--color-primary-950: #3b0764; /* Added for deeper contrast */
|
|
||||||
|
|
||||||
/* Enhanced Pink gradient for event highlights */
|
|
||||||
--color-accent-300: #f9a8d4;
|
|
||||||
--color-accent-400: #f472b6;
|
|
||||||
--color-accent-500: #ec4899;
|
|
||||||
--color-accent-600: #db2777;
|
|
||||||
--color-accent-700: #be185d; /* Added for better hierarchy */
|
|
||||||
|
|
||||||
/* Enhanced Neutral system with warmer tones */
|
|
||||||
--color-neutral-50: #f8fafc;
|
|
||||||
--color-neutral-100: #f1f5f9;
|
|
||||||
--color-neutral-200: #e2e8f0;
|
|
||||||
--color-neutral-300: #cbd5e1;
|
|
||||||
--color-neutral-400: #94a3b8;
|
|
||||||
--color-neutral-500: #64748b;
|
|
||||||
--color-neutral-600: #475569;
|
|
||||||
--color-neutral-700: #334155;
|
|
||||||
--color-neutral-800: #1e293b;
|
|
||||||
--color-neutral-900: #0f172a;
|
|
||||||
--color-neutral-950: #020617; /* Added for deeper backgrounds */
|
|
||||||
|
|
||||||
/* Event-specific semantic colors */
|
|
||||||
--color-success-light: #dcfce7;
|
|
||||||
--color-success: #16a34a;
|
|
||||||
--color-success-dark: #15803d;
|
|
||||||
--color-warning-light: #fef3c7;
|
|
||||||
--color-warning: #f59e0b;
|
|
||||||
--color-warning-dark: #d97706;
|
|
||||||
--color-danger-light: #fee2e2;
|
|
||||||
--color-danger: #dc2626;
|
|
||||||
--color-danger-dark: #b91c1c;
|
|
||||||
|
|
||||||
/* Event status colors */
|
|
||||||
--color-event-featured: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
|
||||||
--color-event-available: var(--color-success);
|
|
||||||
--color-event-limited: var(--color-warning);
|
|
||||||
--color-event-sold-out: var(--color-danger);
|
|
||||||
--color-event-vip: linear-gradient(135deg, #ffd700 0%, #ffb347 100%);
|
|
||||||
|
|
||||||
/* Enhanced Typography with better hierarchy */
|
|
||||||
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
--font-display: 'Outfit', var(--font-sans); /* For headings and key content */
|
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', monospace;
|
|
||||||
|
|
||||||
/* Enhanced font sizes with golden ratio scaling */
|
|
||||||
--text-xs: 0.75rem; /* 12px */
|
|
||||||
--text-sm: 0.875rem; /* 14px */
|
|
||||||
--text-base: 1rem; /* 16px */
|
|
||||||
--text-lg: 1.125rem; /* 18px */
|
|
||||||
--text-xl: 1.25rem; /* 20px */
|
|
||||||
--text-2xl: 1.5rem; /* 24px */
|
|
||||||
--text-3xl: 1.875rem; /* 30px */
|
|
||||||
--text-4xl: 2.25rem; /* 36px */
|
|
||||||
--text-5xl: 3rem; /* 48px - for hero sections */
|
|
||||||
--text-6xl: 3.75rem; /* 60px - for major headings */
|
|
||||||
|
|
||||||
/* Enhanced spacing system */
|
|
||||||
--space-px: 1px;
|
|
||||||
--space-0-5: 0.125rem; /* 2px */
|
|
||||||
--space-1: 0.25rem; /* 4px */
|
|
||||||
--space-1-5: 0.375rem; /* 6px */
|
|
||||||
--space-2: 0.5rem; /* 8px */
|
|
||||||
--space-2-5: 0.625rem; /* 10px */
|
|
||||||
--space-3: 0.75rem; /* 12px */
|
|
||||||
--space-3-5: 0.875rem; /* 14px */
|
|
||||||
--space-4: 1rem; /* 16px */
|
|
||||||
--space-5: 1.25rem; /* 20px */
|
|
||||||
--space-6: 1.5rem; /* 24px */
|
|
||||||
--space-7: 1.75rem; /* 28px */
|
|
||||||
--space-8: 2rem; /* 32px */
|
|
||||||
--space-9: 2.25rem; /* 36px */
|
|
||||||
--space-10: 2.5rem; /* 40px */
|
|
||||||
--space-11: 2.75rem; /* 44px */
|
|
||||||
--space-12: 3rem; /* 48px */
|
|
||||||
--space-14: 3.5rem; /* 56px */
|
|
||||||
--space-16: 4rem; /* 64px */
|
|
||||||
--space-20: 5rem; /* 80px */
|
|
||||||
--space-24: 6rem; /* 96px */
|
|
||||||
|
|
||||||
/* Enhanced border radius system */
|
|
||||||
--radius-none: 0px;
|
|
||||||
--radius-sm: 0.25rem; /* 4px */
|
|
||||||
--radius-md: 0.375rem; /* 6px */
|
|
||||||
--radius: 0.5rem; /* 8px */
|
|
||||||
--radius-lg: 0.75rem; /* 12px */
|
|
||||||
--radius-xl: 1rem; /* 16px */
|
|
||||||
--radius-2xl: 1.5rem; /* 24px */
|
|
||||||
--radius-3xl: 2rem; /* 32px */
|
|
||||||
--radius-full: 9999px;
|
|
||||||
|
|
||||||
/* Enhanced shadow system with color variations */
|
|
||||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
||||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
|
||||||
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
|
|
||||||
|
|
||||||
/* Purple-tinted shadows for premium feel */
|
|
||||||
--shadow-purple-sm: 0 1px 3px 0 rgb(147 51 234 / 0.1), 0 1px 2px -1px rgb(147 51 234 / 0.1);
|
|
||||||
--shadow-purple-md: 0 4px 6px -1px rgb(147 51 234 / 0.1), 0 2px 4px -2px rgb(147 51 234 / 0.1);
|
|
||||||
--shadow-purple-lg: 0 10px 15px -3px rgb(147 51 234 / 0.15), 0 4px 6px -4px rgb(147 51 234 / 0.1);
|
|
||||||
|
|
||||||
/* Pink-tinted shadows for event highlights */
|
|
||||||
--shadow-pink-sm: 0 1px 3px 0 rgb(236 72 153 / 0.1), 0 1px 2px -1px rgb(236 72 153 / 0.1);
|
|
||||||
--shadow-pink-md: 0 4px 6px -1px rgb(236 72 153 / 0.1), 0 2px 4px -2px rgb(236 72 153 / 0.1);
|
|
||||||
|
|
||||||
/* Animation durations */
|
|
||||||
--duration-fast: 0.15s;
|
|
||||||
--duration-normal: 0.2s;
|
|
||||||
--duration-slow: 0.3s;
|
|
||||||
--duration-slower: 0.5s;
|
|
||||||
|
|
||||||
/* Easing functions */
|
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
|
||||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
|
||||||
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Component Styles */
|
|
||||||
|
|
||||||
/* Buttons with improved hierarchy */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: all var(--duration-normal) var(--ease-in-out);
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-md {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-lg {
|
|
||||||
padding: var(--space-4) var(--space-6);
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
|
||||||
color: white;
|
|
||||||
box-shadow: var(--shadow-purple-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--color-accent-600) 100%);
|
|
||||||
box-shadow: var(--shadow-purple-md);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: var(--shadow-purple-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: white;
|
|
||||||
color: var(--color-primary-600);
|
|
||||||
border: 1px solid var(--color-neutral-200);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--color-primary-50);
|
|
||||||
border-color: var(--color-primary-300);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-primary-600);
|
|
||||||
border: 2px solid var(--color-primary-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background: var(--color-primary-600);
|
|
||||||
color: white;
|
|
||||||
box-shadow: var(--shadow-purple-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-neutral-600);
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover {
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
color: var(--color-primary-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Cards */
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
border: 1px solid var(--color-neutral-200);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all var(--duration-normal) var(--ease-in-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-interactive {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-interactive:hover {
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
transform: translateY(-4px);
|
|
||||||
border-color: var(--color-primary-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
padding: var(--space-6);
|
|
||||||
border-bottom: 1px solid var(--color-neutral-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer {
|
|
||||||
padding: var(--space-4) var(--space-6);
|
|
||||||
background: var(--color-neutral-50);
|
|
||||||
border-top: 1px solid var(--color-neutral-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Event-specific cards */
|
|
||||||
.event-card {
|
|
||||||
position: relative;
|
|
||||||
background: white;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all var(--duration-slow) var(--ease-out);
|
|
||||||
border: 1px solid var(--color-neutral-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card:hover {
|
|
||||||
box-shadow: var(--shadow-2xl);
|
|
||||||
transform: translateY(-6px) scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card-featured {
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background: linear-gradient(white, white) padding-box,
|
|
||||||
linear-gradient(135deg, var(--color-primary-600), var(--color-accent-500)) border-box;
|
|
||||||
box-shadow: var(--shadow-purple-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card-featured::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 4px;
|
|
||||||
background: var(--color-event-featured);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card-image {
|
|
||||||
aspect-ratio: 16/9;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card-image img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: transform var(--duration-slow) var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card:hover .event-card-image img {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Forms */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-neutral-700);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input,
|
|
||||||
.form-select,
|
|
||||||
.form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--color-neutral-900);
|
|
||||||
background: white;
|
|
||||||
border: 1px solid var(--color-neutral-300);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: all var(--duration-normal) var(--ease-in-out);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus,
|
|
||||||
.form-select:focus,
|
|
||||||
.form-textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary-500);
|
|
||||||
box-shadow: 0 0 0 3px rgb(168 85 247 / 0.1);
|
|
||||||
background: var(--color-primary-50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
|
||||||
color: var(--color-danger);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Badges */
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-1-5) var(--space-3);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-available {
|
|
||||||
background: var(--color-success-light);
|
|
||||||
color: var(--color-success-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-limited {
|
|
||||||
background: var(--color-warning-light);
|
|
||||||
color: var(--color-warning-dark);
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-sold-out {
|
|
||||||
background: var(--color-danger-light);
|
|
||||||
color: var(--color-danger-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-featured {
|
|
||||||
background: linear-gradient(135deg, var(--color-primary-100), var(--color-accent-100));
|
|
||||||
color: var(--color-primary-800);
|
|
||||||
border: 1px solid var(--color-primary-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-vip {
|
|
||||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
|
||||||
color: #92400e;
|
|
||||||
border: 1px solid #fbbf24;
|
|
||||||
box-shadow: var(--shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Navigation */
|
|
||||||
.nav {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid var(--color-neutral-200);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-neutral-600);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: all var(--duration-normal) var(--ease-in-out);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover,
|
|
||||||
.nav-link.active {
|
|
||||||
color: var(--color-primary-600);
|
|
||||||
background: var(--color-primary-50);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Layout */
|
|
||||||
.container {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.container { padding: 0 var(--space-6); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.container { padding: 0 var(--space-8); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-responsive {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-6);
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.grid-responsive { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.grid-responsive { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-events {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-8);
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.grid-events { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.grid-events { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
|
||||||
.grid-events { grid-template-columns: repeat(4, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced animations */
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInRight {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fadeInUp {
|
|
||||||
animation: fadeInUp 0.6s var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slideInRight {
|
|
||||||
animation: slideInRight 0.4s var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse {
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-shimmer {
|
|
||||||
background: linear-gradient(90deg, var(--color-neutral-100) 25%, var(--color-neutral-200) 50%, var(--color-neutral-100) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced hover effects */
|
|
||||||
.hover-lift {
|
|
||||||
transition: transform var(--duration-normal) var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-lift:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-glow {
|
|
||||||
transition: all var(--duration-normal) var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-glow:hover {
|
|
||||||
box-shadow: var(--shadow-purple-lg);
|
|
||||||
filter: brightness(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus states with better accessibility */
|
|
||||||
.focus-ring {
|
|
||||||
transition: all var(--duration-fast) var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.focus-ring:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 3px rgb(168 85 247 / 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode enhancements */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: var(--color-neutral-900);
|
|
||||||
--color-surface: var(--color-neutral-800);
|
|
||||||
--color-border: var(--color-neutral-700);
|
|
||||||
--color-text-primary: var(--color-neutral-50);
|
|
||||||
--color-text-secondary: var(--color-neutral-300);
|
|
||||||
--color-text-muted: var(--color-neutral-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--color-neutral-800);
|
|
||||||
border-color: var(--color-neutral-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input,
|
|
||||||
.form-select,
|
|
||||||
.form-textarea {
|
|
||||||
background: var(--color-neutral-700);
|
|
||||||
border-color: var(--color-neutral-600);
|
|
||||||
color: var(--color-neutral-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
background: rgba(30, 41, 59, 0.95);
|
|
||||||
border-bottom-color: var(--color-neutral-700);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Print styles */
|
|
||||||
@media print {
|
|
||||||
.btn,
|
|
||||||
.nav,
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: none !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card {
|
|
||||||
break-inside: avoid;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>APERONIGHT - RADICAL EVENT BOOKING</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
<link rel="stylesheet" href="neo_brutalist_theme.css">
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--background);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
var(--secondary) 0px,
|
|
||||||
var(--secondary) 20px,
|
|
||||||
transparent 20px,
|
|
||||||
transparent 40px
|
|
||||||
), var(--background);
|
|
||||||
padding: 100px 0;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
border-bottom: 6px solid var(--border);
|
|
||||||
box-shadow: 0 6px 0px 0px var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
left: 20px;
|
|
||||||
right: 20px;
|
|
||||||
bottom: 20px;
|
|
||||||
border: 4px solid var(--border);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
font-size: clamp(2.5rem, 8vw, 6rem);
|
|
||||||
font-weight: 900;
|
|
||||||
margin-bottom: var(--space-8);
|
|
||||||
color: var(--foreground);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
text-shadow: 4px 4px 0px var(--accent);
|
|
||||||
animation: glitch 3s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glitch {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
20% { transform: translateX(-2px); }
|
|
||||||
40% { transform: translateX(2px); }
|
|
||||||
60% { transform: translateX(-1px); }
|
|
||||||
80% { transform: translateX(1px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero p {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: var(--space-12);
|
|
||||||
max-width: 800px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-section {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-8);
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 var(--space-4);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
padding: var(--space-20) 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section:nth-child(odd) {
|
|
||||||
background: linear-gradient(135deg, var(--secondary) 25%, transparent 25%),
|
|
||||||
linear-gradient(225deg, var(--secondary) 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, var(--secondary) 25%, transparent 25%),
|
|
||||||
linear-gradient(315deg, var(--secondary) 25%, var(--background) 25%);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
background-position: 0 0, 0 20px, 20px -20px, -20px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: clamp(2rem, 6vw, 4rem);
|
|
||||||
font-weight: 900;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: var(--space-16);
|
|
||||||
color: var(--foreground);
|
|
||||||
text-transform: uppercase;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title::after {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
width: 100px;
|
|
||||||
height: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
margin: var(--space-4) auto 0;
|
|
||||||
box-shadow: var(--shadow-brutal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.events-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
||||||
gap: var(--space-12);
|
|
||||||
margin-bottom: var(--space-16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-event-card {
|
|
||||||
background: var(--background);
|
|
||||||
border: 4px solid var(--border);
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-event-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
left: -8px;
|
|
||||||
right: -20px;
|
|
||||||
bottom: -20px;
|
|
||||||
background: var(--primary);
|
|
||||||
z-index: -1;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-event-card:hover {
|
|
||||||
transform: translate(8px, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-event-card:hover::before {
|
|
||||||
transform: translate(-8px, -8px);
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 250px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-bottom: 4px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-content {
|
|
||||||
padding: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-title {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: 900;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
color: var(--foreground);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-date {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-description {
|
|
||||||
color: var(--foreground);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-price {
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--secondary);
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
box-shadow: var(--shadow-brutal);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: var(--space-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-feature {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-8);
|
|
||||||
background: var(--background);
|
|
||||||
border: 4px solid var(--border);
|
|
||||||
position: relative;
|
|
||||||
box-shadow: var(--shadow-brutal-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-feature::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -12px;
|
|
||||||
left: -12px;
|
|
||||||
right: -24px;
|
|
||||||
bottom: -24px;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
var(--accent) 0px,
|
|
||||||
var(--accent) 10px,
|
|
||||||
transparent 10px,
|
|
||||||
transparent 20px
|
|
||||||
);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-icon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
border: 4px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 auto var(--space-6);
|
|
||||||
box-shadow: var(--shadow-brutal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-feature h3 {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: 900;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
color: var(--foreground);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brutal-feature p {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats {
|
|
||||||
background: var(--foreground);
|
|
||||||
color: var(--background);
|
|
||||||
padding: var(--space-20) 0;
|
|
||||||
border-top: 6px solid var(--accent);
|
|
||||||
border-bottom: 6px solid var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: var(--space-12);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
padding: var(--space-8);
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
border: 4px solid var(--background);
|
|
||||||
box-shadow: var(--shadow-brutal-xl);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
left: -8px;
|
|
||||||
right: -16px;
|
|
||||||
bottom: -16px;
|
|
||||||
background: var(--accent);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: clamp(2.5rem, 6vw, 4rem);
|
|
||||||
font-weight: 900;
|
|
||||||
color: var(--primary);
|
|
||||||
display: block;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: var(--foreground);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
font-weight: 900;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
background: var(--border);
|
|
||||||
color: var(--background);
|
|
||||||
padding: var(--space-16) 0;
|
|
||||||
border-top: 6px solid var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-8);
|
|
||||||
margin-bottom: var(--space-8);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links a {
|
|
||||||
color: var(--background);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 900;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border: 3px solid var(--background);
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links a:hover {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--border);
|
|
||||||
box-shadow: 4px 4px 0px 0px var(--accent);
|
|
||||||
transform: translate(-2px, -2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noise-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
opacity: 0.03;
|
|
||||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='1' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.cta-section {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.events-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="noise-overlay"></div>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<nav class="nav">
|
|
||||||
<div class="container">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--space-4) 0;">
|
|
||||||
<div style="display: flex; align-items: center; gap: var(--space-4);">
|
|
||||||
<div style="width: 50px; height: 50px; background: var(--primary); border: 4px solid var(--border); display: flex; align-items: center; justify-content: center; color: white; font-weight: 900; box-shadow: var(--shadow-brutal); font-size: var(--text-xl);">A</div>
|
|
||||||
<span style="font-size: var(--text-2xl); font-weight: 900; color: var(--foreground); text-transform: uppercase; letter-spacing: -0.02em;">APERONIGHT</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: var(--space-4); align-items: center;">
|
|
||||||
<a href="#" class="nav-link">EVENTS</a>
|
|
||||||
<a href="#" class="nav-link">ABOUT</a>
|
|
||||||
<a href="#" class="nav-link">CONTACT</a>
|
|
||||||
<button class="btn-primary">SIGN IN</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Hero Section -->
|
|
||||||
<section class="hero">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="glitch-text" data-text="RADICAL EVENTS">RADICAL EVENTS</h1>
|
|
||||||
<p>BREAK THE BORING. JOIN THE REVOLUTION. EXPERIENCE EVENTS THAT MATTER.</p>
|
|
||||||
<div class="cta-section">
|
|
||||||
<button class="btn-primary">FIND EVENTS</button>
|
|
||||||
<div style="width: 4px; height: 60px; background: var(--border); box-shadow: var(--shadow-brutal);"></div>
|
|
||||||
<button class="btn-secondary">HOST EVENT</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Featured Events -->
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">FEATURED CHAOS</h2>
|
|
||||||
<div class="events-grid">
|
|
||||||
<div class="brutal-event-card">
|
|
||||||
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=250&fit=crop" alt="TECH CHAOS" class="event-image">
|
|
||||||
<div class="event-content">
|
|
||||||
<h3 class="event-title">TECH CHAOS NIGHT</h3>
|
|
||||||
<div class="event-date">
|
|
||||||
<i data-lucide="zap" style="width: 24px; height: 24px;"></i>
|
|
||||||
THU MAR 15 • 6PM
|
|
||||||
</div>
|
|
||||||
<p class="event-description">SMASH NETWORKING BARRIERS. CODE. DRINKS. CHAOS. REPEAT.</p>
|
|
||||||
<div class="event-footer">
|
|
||||||
<span class="event-price">€25</span>
|
|
||||||
<div style="display: flex; gap: var(--space-3);">
|
|
||||||
<span class="badge-available">LIVE</span>
|
|
||||||
<button class="btn-destructive" style="padding: var(--space-3) var(--space-4);">GRAB IT</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="brutal-event-card">
|
|
||||||
<img src="https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=250&fit=crop" alt="SOUND ASSAULT" class="event-image">
|
|
||||||
<div class="event-content">
|
|
||||||
<h3 class="event-title">SOUND ASSAULT</h3>
|
|
||||||
<div class="event-date">
|
|
||||||
<i data-lucide="volume-2" style="width: 24px; height: 24px;"></i>
|
|
||||||
SAT MAR 18 • 8PM
|
|
||||||
</div>
|
|
||||||
<p class="event-description">UNDERGROUND BEATS. ROOF ACCESS. CITY DOMINATION.</p>
|
|
||||||
<div class="event-footer">
|
|
||||||
<span class="event-price">€35</span>
|
|
||||||
<div style="display: flex; gap: var(--space-3);">
|
|
||||||
<span class="badge-featured">★ HOT</span>
|
|
||||||
<button class="btn-primary" style="padding: var(--space-3) var(--space-4);">INVADE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="brutal-event-card">
|
|
||||||
<img src="https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=400&h=250&fit=crop" alt="ART REBELLION" class="event-image">
|
|
||||||
<div class="event-content">
|
|
||||||
<h3 class="event-title">ART REBELLION</h3>
|
|
||||||
<div class="event-date">
|
|
||||||
<i data-lucide="paintbrush" style="width: 24px; height: 24px;"></i>
|
|
||||||
FRI MAR 22 • 7PM
|
|
||||||
</div>
|
|
||||||
<p class="event-description">DESTROY CONVENTIONS. CREATE CHAOS. WINE INCLUDED.</p>
|
|
||||||
<div class="event-footer">
|
|
||||||
<span class="event-price">€20</span>
|
|
||||||
<div style="display: flex; gap: var(--space-3);">
|
|
||||||
<span class="badge-sold-out">DANGER</span>
|
|
||||||
<button class="btn-secondary" style="padding: var(--space-3) var(--space-4);">RISK IT</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<button class="btn-secondary" style="font-size: var(--text-xl); padding: var(--space-4) var(--space-8);">MORE CHAOS</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Features -->
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">WHY WE RULE</h2>
|
|
||||||
<div class="features-grid">
|
|
||||||
<div class="brutal-feature">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i data-lucide="skull" style="width: 40px; height: 40px;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>CURATED MADNESS</h3>
|
|
||||||
<p>HANDPICKED EVENTS THAT DESTROY BORING AND CREATE LEGENDS.</p>
|
|
||||||
</div>
|
|
||||||
<div class="brutal-feature">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i data-lucide="shield" style="width: 40px; height: 40px;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>BULLETPROOF BOOKING</h3>
|
|
||||||
<p>SECURE PAYMENTS. INSTANT TICKETS. NO BULLSHIT REFUNDS.</p>
|
|
||||||
</div>
|
|
||||||
<div class="brutal-feature">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i data-lucide="rocket" style="width: 40px; height: 40px;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>ZERO FRICTION</h3>
|
|
||||||
<p>FIND EVENT. BOOK TICKET. DESTROY EXPECTATIONS. REPEAT.</p>
|
|
||||||
</div>
|
|
||||||
<div class="brutal-feature">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i data-lucide="users" style="width: 40px; height: 40px;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>TRIBE BUILDING</h3>
|
|
||||||
<p>CONNECT WITH REBELS WHO GET IT. BUILD YOUR EMPIRE.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<section class="stats">
|
|
||||||
<div class="container">
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">50+</span>
|
|
||||||
<div class="stat-label">EVENTS MONTHLY</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">2.5K</span>
|
|
||||||
<div class="stat-label">REBELS JOINED</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">100+</span>
|
|
||||||
<div class="stat-label">VENUES CONQUERED</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">95%</span>
|
|
||||||
<div class="stat-label">MINDS BLOWN</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<div class="footer-content">
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="#">ABOUT</a>
|
|
||||||
<a href="#">EVENTS</a>
|
|
||||||
<a href="#">SUPPORT</a>
|
|
||||||
<a href="#">PRIVACY</a>
|
|
||||||
<a href="#">TERMS</a>
|
|
||||||
</div>
|
|
||||||
<p style="font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em;">© 2024 APERONIGHT. CHAOS RESERVED.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Initialize Lucide icons
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// Add brutal animations on scroll
|
|
||||||
const observerOptions = {
|
|
||||||
threshold: 0.2,
|
|
||||||
rootMargin: '0px 0px -100px 0px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.style.animation = 'none';
|
|
||||||
entry.target.offsetHeight; // Trigger reflow
|
|
||||||
entry.target.style.animation = 'shake-brutal 0.5s ease-in-out';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, observerOptions);
|
|
||||||
|
|
||||||
document.querySelectorAll('.brutal-event-card, .brutal-feature, .stat-item').forEach(el => {
|
|
||||||
observer.observe(el);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add random glitch effects
|
|
||||||
setInterval(() => {
|
|
||||||
const elements = document.querySelectorAll('.section-title, .event-title');
|
|
||||||
const randomElement = elements[Math.floor(Math.random() * elements.length)];
|
|
||||||
if (randomElement && Math.random() > 0.9) {
|
|
||||||
randomElement.style.animation = 'glitch 0.3s ease-in-out';
|
|
||||||
setTimeout(() => {
|
|
||||||
randomElement.style.animation = '';
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
/* Neo-Brutalist Design System for Event Booking */
|
|
||||||
:root {
|
|
||||||
/* Colors - Bold and high contrast */
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #000000;
|
|
||||||
--card: #ffffff;
|
|
||||||
--card-foreground: #000000;
|
|
||||||
--popover: #ffffff;
|
|
||||||
--popover-foreground: #000000;
|
|
||||||
--primary: #ff6b35;
|
|
||||||
--primary-foreground: #ffffff;
|
|
||||||
--secondary: #00f5ff;
|
|
||||||
--secondary-foreground: #000000;
|
|
||||||
--muted: #f0f0f0;
|
|
||||||
--muted-foreground: #333333;
|
|
||||||
--accent: #ff1744;
|
|
||||||
--accent-foreground: #ffffff;
|
|
||||||
--destructive: #000000;
|
|
||||||
--destructive-foreground: #ffffff;
|
|
||||||
--border: #000000;
|
|
||||||
--input: #ffffff;
|
|
||||||
--ring: #ff6b35;
|
|
||||||
|
|
||||||
/* Event-specific colors - Bold and vibrant */
|
|
||||||
--event-featured: #7c4dff;
|
|
||||||
--event-sold-out: #000000;
|
|
||||||
--event-available: #00c853;
|
|
||||||
--ticket-premium: #ffc107;
|
|
||||||
--ticket-standard: #9e9e9e;
|
|
||||||
|
|
||||||
/* Typography - Bold and impactful */
|
|
||||||
--font-sans: 'Space Grotesk', 'Arial Black', sans-serif;
|
|
||||||
--font-mono: 'Space Mono', 'Courier New', monospace;
|
|
||||||
|
|
||||||
/* Font sizes - Exaggerated scale */
|
|
||||||
--text-xs: 0.75rem;
|
|
||||||
--text-sm: 0.875rem;
|
|
||||||
--text-base: 1rem;
|
|
||||||
--text-lg: 1.25rem;
|
|
||||||
--text-xl: 1.5rem;
|
|
||||||
--text-2xl: 2rem;
|
|
||||||
--text-3xl: 2.5rem;
|
|
||||||
--text-4xl: 3.5rem;
|
|
||||||
|
|
||||||
/* Spacing - Generous */
|
|
||||||
--space-1: 0.25rem;
|
|
||||||
--space-2: 0.5rem;
|
|
||||||
--space-3: 0.75rem;
|
|
||||||
--space-4: 1rem;
|
|
||||||
--space-6: 1.5rem;
|
|
||||||
--space-8: 2rem;
|
|
||||||
--space-12: 3rem;
|
|
||||||
--space-16: 4rem;
|
|
||||||
--space-20: 5rem;
|
|
||||||
|
|
||||||
/* Border radius - Sharp edges */
|
|
||||||
--radius: 0px;
|
|
||||||
--radius-sm: 0px;
|
|
||||||
--radius-md: 0px;
|
|
||||||
--radius-lg: 0px;
|
|
||||||
--radius-xl: 0px;
|
|
||||||
|
|
||||||
/* Shadows - Bold and offset */
|
|
||||||
--shadow-brutal: 8px 8px 0px 0px #000000;
|
|
||||||
--shadow-brutal-lg: 12px 12px 0px 0px #000000;
|
|
||||||
--shadow-brutal-xl: 16px 16px 0px 0px #000000;
|
|
||||||
--shadow-brutal-color: 8px 8px 0px 0px var(--accent);
|
|
||||||
--shadow-inset: inset 4px 4px 0px 0px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typography overrides */
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-family: var(--font-sans) !important;
|
|
||||||
font-weight: 900 !important;
|
|
||||||
text-transform: uppercase !important;
|
|
||||||
letter-spacing: -0.02em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: var(--text-4xl) !important; }
|
|
||||||
h2 { font-size: var(--text-3xl) !important; }
|
|
||||||
h3 { font-size: var(--text-2xl) !important; }
|
|
||||||
|
|
||||||
/* Component styles */
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
padding: 16px 32px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-weight: 900;
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
box-shadow: var(--shadow-brutal);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translate(4px, 4px);
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:active {
|
|
||||||
transform: translate(8px, 8px);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--secondary);
|
|
||||||
color: var(--secondary-foreground);
|
|
||||||
padding: 16px 32px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-weight: 900;
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
box-shadow: var(--shadow-brutal);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
transform: translate(4px, 4px);
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-destructive {
|
|
||||||
background: var(--destructive);
|
|
||||||
color: var(--destructive-foreground);
|
|
||||||
padding: 16px 32px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-weight: 900;
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
box-shadow: var(--shadow-brutal-color);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow-brutal-lg);
|
|
||||||
padding: var(--space-8);
|
|
||||||
border: 4px solid var(--border);
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
left: -4px;
|
|
||||||
right: -16px;
|
|
||||||
bottom: -16px;
|
|
||||||
background: var(--accent);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translate(4px, 4px);
|
|
||||||
box-shadow: 8px 8px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card {
|
|
||||||
background: var(--card);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: visible;
|
|
||||||
box-shadow: var(--shadow-brutal-xl);
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
border: 4px solid var(--border);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
left: -8px;
|
|
||||||
right: -24px;
|
|
||||||
bottom: -24px;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 10px,
|
|
||||||
var(--secondary) 10px,
|
|
||||||
var(--secondary) 20px
|
|
||||||
);
|
|
||||||
z-index: -1;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card:hover::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card:hover {
|
|
||||||
transform: translate(8px, 8px);
|
|
||||||
box-shadow: 8px 8px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-card {
|
|
||||||
background: var(--muted);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: var(--space-6);
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
box-shadow: var(--shadow-brutal);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-card.selected {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
transform: translate(4px, 4px);
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-card.selected::before {
|
|
||||||
content: '✓';
|
|
||||||
position: absolute;
|
|
||||||
top: -10px;
|
|
||||||
right: -10px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 900;
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
background: var(--input);
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: 700;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
box-shadow: var(--shadow-inset);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: var(--shadow-brutal-color);
|
|
||||||
transform: translate(-2px, -2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 900;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-available {
|
|
||||||
background: var(--event-available);
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-sold-out {
|
|
||||||
background: var(--event-sold-out);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-featured {
|
|
||||||
background: var(--event-featured);
|
|
||||||
color: #ffffff;
|
|
||||||
animation: pulse-brutal 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-premium {
|
|
||||||
background: var(--ticket-premium);
|
|
||||||
color: #000000;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-premium::after {
|
|
||||||
content: '★';
|
|
||||||
margin-left: 8px;
|
|
||||||
animation: rotate 3s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation */
|
|
||||||
.nav {
|
|
||||||
background: var(--background);
|
|
||||||
border-bottom: 6px solid var(--border);
|
|
||||||
box-shadow: 0 6px 0px 0px var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-weight: 900;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
text-decoration: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
border-color: var(--border);
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
transform: translate(-2px, -2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes pulse-brutal {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 6px 6px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake-brutal {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-8px); }
|
|
||||||
75% { transform: translateX(8px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-shake {
|
|
||||||
animation: shake-brutal 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Special effects */
|
|
||||||
.glitch-text {
|
|
||||||
position: relative;
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glitch-text::before,
|
|
||||||
.glitch-text::after {
|
|
||||||
content: attr(data-text);
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glitch-text::before {
|
|
||||||
color: var(--accent);
|
|
||||||
animation: glitch-1 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glitch-text::after {
|
|
||||||
color: var(--secondary);
|
|
||||||
animation: glitch-2 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glitch-1 {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
20% { transform: translateX(-2px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glitch-2 {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
20% { transform: translateX(2px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
:root {
|
|
||||||
--space-4: 0.75rem;
|
|
||||||
--space-6: 1rem;
|
|
||||||
--space-8: 1.5rem;
|
|
||||||
--text-4xl: 2.5rem;
|
|
||||||
--text-3xl: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary, .btn-secondary, .btn-destructive {
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-brutal, .shadow-brutal-lg, .shadow-brutal-xl {
|
|
||||||
box-shadow: 4px 4px 0px 0px #000000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
AGENT.md
33
AGENT.md
@@ -256,6 +256,38 @@ events = Event.create!([...])
|
|||||||
ticket_types = TicketType.create!([...])
|
ticket_types = TicketType.create!([...])
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🛠️ Available Development Tools
|
||||||
|
|
||||||
|
### AST-Grep for Mass Code Replacement
|
||||||
|
|
||||||
|
The system has `ast-grep` installed for structural code search and replacement. This tool is particularly useful for:
|
||||||
|
|
||||||
|
- **Mass refactoring**: Rename methods, classes, or variables across the codebase
|
||||||
|
- **Pattern-based replacements**: Update code patterns using AST matching
|
||||||
|
- **Language-aware transformations**: Safer than regex for code modifications
|
||||||
|
|
||||||
|
#### Usage Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find all method calls to a specific method
|
||||||
|
ast-grep --pattern 'find_by_$FIELD($VALUE)' --lang ruby
|
||||||
|
|
||||||
|
# Replace method calls with new syntax
|
||||||
|
ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)' --lang ruby
|
||||||
|
|
||||||
|
# Search for specific Rails patterns
|
||||||
|
ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby
|
||||||
|
|
||||||
|
# Mass rename across multiple files
|
||||||
|
ast-grep --pattern 'old_method_name($$$ARGS)' --rewrite 'new_method_name($$$ARGS)' --lang ruby --update-all
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Best Practices:
|
||||||
|
- Always run with `--dry-run` first to preview changes
|
||||||
|
- Use `--lang ruby` for Ruby files to ensure proper AST parsing
|
||||||
|
- Test changes in a branch before applying to main codebase
|
||||||
|
- Particularly useful for Rails conventions and ActiveRecord pattern updates
|
||||||
|
|
||||||
## 📝 Code Style & Conventions
|
## 📝 Code Style & Conventions
|
||||||
|
|
||||||
- **Ruby Style**: Follow Rails conventions and Rubocop rules
|
- **Ruby Style**: Follow Rails conventions and Rubocop rules
|
||||||
@@ -263,5 +295,6 @@ ticket_types = TicketType.create!([...])
|
|||||||
- **JavaScript**: Stimulus controllers for interactive behavior
|
- **JavaScript**: Stimulus controllers for interactive behavior
|
||||||
- **CSS**: Tailwind utility classes with custom components
|
- **CSS**: Tailwind utility classes with custom components
|
||||||
- **Documentation**: Inline comments for complex business logic
|
- **Documentation**: Inline comments for complex business logic
|
||||||
|
- **Mass Changes**: Use `ast-grep` for structural code replacements instead of simple find/replace
|
||||||
|
|
||||||
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience.
|
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience.
|
||||||
92
BACKLOG.md
92
BACKLOG.md
@@ -1,44 +1,70 @@
|
|||||||
# Project Backlog
|
# Project Backlog
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 📋 Todo
|
## 📋 Todo
|
||||||
- [ ] Set up project infrastructure
|
|
||||||
- [ ] Design user interface mockups
|
### High Priority
|
||||||
- [ ] Create user dashboard
|
|
||||||
- [ ] Implement data persistence
|
- [ ] feat: Check-in system with QR code scanning
|
||||||
- [ ] Add responsive design
|
|
||||||
- [ ] Write unit tests
|
### Medium Priority
|
||||||
- [ ] Set up CI/CD pipeline
|
|
||||||
- [ ] Add error handling
|
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
||||||
- [ ] Implement search functionality
|
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
|
||||||
- [ ] Add user profile management
|
- [ ] feat: Refund management system
|
||||||
- [ ] Create admin panel
|
- [ ] feat: Real-time sales analytics dashboard
|
||||||
- [ ] Optimize performance
|
- [ ] feat: Guest checkout without account creation
|
||||||
- [ ] Add documentation
|
- [ ] feat: Seat selection with interactive venue maps
|
||||||
- [ ] Security audit
|
- [ ] feat: Dynamic pricing based on demand
|
||||||
- [ ] Deploy to production
|
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||||
|
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||||
|
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
||||||
|
- [ ] feat: Platform commission tracking and fee structure display
|
||||||
|
- [ ] feat: Tax reporting and revenue export for promoters
|
||||||
|
- [ ] feat: Event update notifications to ticket holders
|
||||||
|
- [ ] feat: Marketing tools with promotional codes and discounts
|
||||||
|
- [ ] feat: Customer support messaging between promoters and attendees
|
||||||
|
- [ ] feat: Attendance tracking (who showed up vs tickets sold)
|
||||||
|
- [ ] feat: Customer insights and demographics for promoters
|
||||||
|
- [ ] feat: Performance metrics and conversion rate analytics
|
||||||
|
- [ ] feat: Event templates for reusing successful formats
|
||||||
|
- [ ] feat: Staff management and role assignment for promoter teams
|
||||||
|
- [ ] feat: Multiple payment gateway options
|
||||||
|
- [ ] feat: Calendar sync (Google Calendar, Outlook integration)
|
||||||
|
- [ ] feat: Social media auto-posting for events
|
||||||
|
- [ ] feat: CRM and email marketing tool integrations
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
- [ ] feat: SMS integration for ticket delivery and updates
|
||||||
|
- [ ] feat: Mobile wallet integration
|
||||||
|
- [ ] feat: Multi-currency support
|
||||||
|
- [ ] feat: Event updates communication system
|
||||||
|
- [ ] feat: Bulk operations for group bookings
|
||||||
|
- [ ] feat: Fraud prevention and bot protection
|
||||||
|
- [ ] feat: Social login options
|
||||||
|
- [ ] feat: Event recommendations system
|
||||||
|
- [ ] feat: Invitation link. As organizer or promoter, you can invite people
|
||||||
|
|
||||||
|
|
||||||
|
### Design & Infrastructure
|
||||||
|
|
||||||
|
- [ ] style: Rewrite design system
|
||||||
|
- [ ] refactor: Rewrite design mockup
|
||||||
|
|
||||||
## 🚧 Doing
|
## 🚧 Doing
|
||||||
- [x] Add login functionality
|
|
||||||
|
|
||||||
|
|
||||||
|
- [ ] feat: Page to display all tickets for an event
|
||||||
|
- [ ] feat: Add a link into notification email to order page that display all tickets
|
||||||
|
|
||||||
## ✅ Done
|
## ✅ Done
|
||||||
- [x] Initialize git repository
|
|
||||||
- [x] Set up development environment
|
|
||||||
- [x] Create project structure
|
|
||||||
- [x] Install dependencies
|
|
||||||
- [x] Configure build tools
|
|
||||||
- [x] Set up linting rules
|
|
||||||
- [x] Create initial README
|
|
||||||
- [x] Set up version control
|
|
||||||
- [x] Configure development server
|
|
||||||
- [x] Establish coding standards
|
|
||||||
- [x] Set up package.json
|
|
||||||
- [x] Create .gitignore file
|
|
||||||
- [x] Initialize npm project
|
|
||||||
- [x] Set up basic folder structure
|
|
||||||
- [x] Configure environment variables
|
- [x] Configure environment variables
|
||||||
- [x] Create authentication system
|
- [x] Create authentication system
|
||||||
- [x] Implement user registration
|
- [x] Implement user registration
|
||||||
|
- [x] Add login functionality
|
||||||
|
- [x] refactor: Moving checkout to OrdersController
|
||||||
|
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
|
||||||
|
- [x] feat: Digital tickets with QR codes
|
||||||
|
- [x] feat: Ticket inventory management and capacity limits
|
||||||
|
- [x] feat: Event discovery with search and filtering
|
||||||
|
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
||||||
|
|||||||
766
CLAUDE.md
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
|
|
||||||
8
Gemfile
8
Gemfile
@@ -57,6 +57,9 @@ group :development, :test do
|
|||||||
|
|
||||||
# Improve Minitest output
|
# Improve Minitest output
|
||||||
gem "minitest-reporters", "~> 1.7"
|
gem "minitest-reporters", "~> 1.7"
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
gem "dotenv-rails"
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
@@ -68,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"
|
||||||
|
|||||||
20
Gemfile.lock
20
Gemfile.lock
@@ -113,6 +113,9 @@ GEM
|
|||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
dotenv (3.1.8)
|
dotenv (3.1.8)
|
||||||
|
dotenv-rails (3.1.8)
|
||||||
|
dotenv (= 3.1.8)
|
||||||
|
railties (>= 6.1)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (5.0.2)
|
erb (5.0.2)
|
||||||
@@ -181,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)
|
||||||
@@ -206,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)
|
||||||
@@ -262,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
|
||||||
@@ -322,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)
|
||||||
@@ -350,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)
|
||||||
@@ -366,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)
|
||||||
@@ -402,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
|
||||||
|
|
||||||
@@ -412,18 +428,21 @@ DEPENDENCIES
|
|||||||
cssbundling-rails
|
cssbundling-rails
|
||||||
debug
|
debug
|
||||||
devise (~> 4.9)
|
devise (~> 4.9)
|
||||||
|
dotenv-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
jsbundling-rails
|
jsbundling-rails
|
||||||
kamal
|
kamal
|
||||||
kaminari (~> 1.2)
|
kaminari (~> 1.2)
|
||||||
kaminari-tailwind (~> 0.1.0)
|
kaminari-tailwind (~> 0.1.0)
|
||||||
minitest-reporters (~> 1.7)
|
minitest-reporters (~> 1.7)
|
||||||
|
mocha
|
||||||
mysql2 (~> 0.5)
|
mysql2 (~> 0.5)
|
||||||
prawn (~> 2.5)
|
prawn (~> 2.5)
|
||||||
prawn-qrcode (~> 0.5)
|
prawn-qrcode (~> 0.5)
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.0.2, >= 8.0.2.1)
|
rails (~> 8.0.2, >= 8.0.2.1)
|
||||||
|
rails-controller-testing
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
@@ -434,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;
|
||||||
|
};
|
||||||
@@ -9,44 +9,7 @@
|
|||||||
/* Import components */
|
/* Import components */
|
||||||
@import "components/hero";
|
@import "components/hero";
|
||||||
@import "components/flash";
|
@import "components/flash";
|
||||||
@import "components/footer";
|
|
||||||
@import "components/event-finder";
|
@import "components/event-finder";
|
||||||
|
|
||||||
/* Import pages */
|
/* Import pages */
|
||||||
@import "pages/home";
|
@import "pages/home";
|
||||||
|
|
||||||
/* Base styles */
|
|
||||||
body {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--color-neutral-900);
|
|
||||||
background: var(--color-neutral-50);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* App wrapper */
|
|
||||||
.app-wrapper {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main content */
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.footer {
|
|
||||||
background: var(--color-neutral-800);
|
|
||||||
color: var(--color-neutral-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flash messages */
|
|
||||||
.flash {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Yield content */
|
|
||||||
.yield {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
|
|
||||||
.footer {
|
|
||||||
background: var(--color-neutral-800);
|
|
||||||
color: var(--color-neutral-300);
|
|
||||||
padding: var(--space-8) 0 var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-6);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.footer-content {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.footer-content {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-section h3 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
color: white;
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links li {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links a {
|
|
||||||
color: var(--color-neutral-400);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color var(--duration-normal);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links a:hover {
|
|
||||||
color: var(--color-accent-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-bottom {
|
|
||||||
border-top: 1px solid var(--color-neutral-700);
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-neutral-400);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hero {
|
|
||||||
padding: var(--space-8) 0 var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-group {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-stats {
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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; }
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
font-size: var(--text-3xl);
|
content: '';
|
||||||
}
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
.hero-content {
|
||||||
font-size: var(--text-2xl);
|
position: relative;
|
||||||
}
|
z-index: 2;
|
||||||
|
color: white;
|
||||||
|
padding: var(--space-16) 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-lg {
|
.hero-title {
|
||||||
padding: var(--space-3) var(--space-6);
|
font-size: var(--text-5xl);
|
||||||
font-size: var(--text-base);
|
font-weight: 900;
|
||||||
}
|
line-height: 1.1;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-md {
|
.hero-accent {
|
||||||
padding: var(--space-2) var(--space-4);
|
color: var(--color-accent-400);
|
||||||
font-size: var(--text-sm);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.form-input,
|
.hero-subtitle {
|
||||||
.form-select,
|
font-size: var(--text-xl);
|
||||||
.form-textarea {
|
font-weight: 500;
|
||||||
padding: var(--space-3);
|
line-height: 1.5;
|
||||||
}
|
margin-bottom: var(--space-8);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 36rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metrics grid */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin: var(--space-8) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-number {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-6);
|
||||||
|
border: 1px solid var(--color-neutral-200);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.hover-lift:hover {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
box-shadow: var(--shadow-2xl);
|
||||||
|
border-color: var(--color-primary-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
border: 1px solid var(--color-neutral-200);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event.hover-glow:hover {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
box-shadow: var(--shadow-2xl);
|
||||||
|
border-color: var(--color-primary-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-4);
|
||||||
|
left: var(--space-4);
|
||||||
|
background: var(--color-accent-400);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-price {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-content {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-event-title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,30 +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
|
||||||
# Charge l'évén avant certaines actions pour réduire les duplications
|
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||||
before_action :set_event, only: [ :show, :update, :destroy ]
|
skip_before_action :authenticate_api_key, only: [ :store_cart ]
|
||||||
|
|
||||||
|
# Loads the event before certain actions to reduce duplications
|
||||||
|
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
|
||||||
|
|
||||||
# GET /api/v1/events
|
# GET /api/v1/events
|
||||||
# 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
|
||||||
@@ -35,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
|
||||||
@@ -47,35 +50,53 @@ 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /api/v1/events/:id/store_cart
|
||||||
|
# Store cart data in session (AJAX endpoint)
|
||||||
|
def store_cart
|
||||||
|
cart_data = params[:cart] || {}
|
||||||
|
session[:pending_cart] = cart_data
|
||||||
|
session[:event_id] = @event.id
|
||||||
|
|
||||||
|
render json: { status: "success", message: "Cart stored successfully" }
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Unknown error"
|
||||||
|
Rails.logger.error "Error storing cart: #{error_message}"
|
||||||
|
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# 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
|
||||||
18
app/controllers/concerns/stripe_concern.rb
Normal file
18
app/controllers/concerns/stripe_concern.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module StripeConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
# Check if Stripe is properly configured
|
||||||
|
def stripe_configured?
|
||||||
|
Rails.application.config.stripe[:secret_key].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Stripe is now initialized at application startup, so this method is no longer needed
|
||||||
|
# but kept for backward compatibility
|
||||||
|
def initialize_stripe
|
||||||
|
return false unless stripe_configured?
|
||||||
|
|
||||||
|
# Stripe is already initialized at application startup
|
||||||
|
Rails.logger.debug "Stripe already initialized at application startup"
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,248 +1,36 @@
|
|||||||
|
# Events controller - Public event listings and individual event display
|
||||||
|
#
|
||||||
|
# This controller manages public event browsing and displays individual events
|
||||||
|
# with their associated ticket types. No authentication required for public browsing.
|
||||||
class EventsController < ApplicationController
|
class EventsController < ApplicationController
|
||||||
before_action :authenticate_user!, only: [ :checkout, :collect_names, :process_names, :payment_success, :download_ticket ]
|
# No authentication required for public event viewing
|
||||||
before_action :set_event, only: [ :show, :checkout, :collect_names, :process_names ]
|
before_action :authenticate_user!, only: []
|
||||||
|
before_action :set_event, only: [ :show ]
|
||||||
|
|
||||||
# Display all events
|
# Display paginated list of upcoming published events
|
||||||
|
#
|
||||||
|
# Shows events in published state, ordered by start time ascending
|
||||||
|
# Includes event owner information and supports Kaminari pagination
|
||||||
def index
|
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
|
||||||
|
#
|
||||||
|
# Shows complete event details including venue information,
|
||||||
|
# available ticket types, and allows users to add tickets to cart
|
||||||
def show
|
def show
|
||||||
# Event is set by set_event callback
|
# Event is set by set_event callback with ticket types preloaded
|
||||||
end
|
# Template will display event details and ticket selection interface
|
||||||
|
|
||||||
# Handle checkout process - Collect names if needed or create Stripe session
|
|
||||||
def checkout
|
|
||||||
cart_data = JSON.parse(params[:cart] || "{}")
|
|
||||||
|
|
||||||
if cart_data.empty?
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if any ticket types require names
|
|
||||||
requires_names = false
|
|
||||||
cart_data.each do |ticket_type_id, item|
|
|
||||||
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
|
||||||
next unless ticket_type
|
|
||||||
|
|
||||||
quantity = item["quantity"].to_i
|
|
||||||
next if quantity <= 0
|
|
||||||
|
|
||||||
if ticket_type.requires_id
|
|
||||||
requires_names = true
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# If names are required, redirect to name collection
|
|
||||||
if requires_names
|
|
||||||
session[:pending_cart] = cart_data
|
|
||||||
redirect_to event_collect_names_path(@event.slug, @event)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Otherwise proceed directly to payment
|
|
||||||
process_payment(cart_data)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Display form to collect names for tickets
|
|
||||||
def collect_names
|
|
||||||
@cart_data = session[:pending_cart] || {}
|
|
||||||
|
|
||||||
if @cart_data.empty?
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Build list of tickets requiring names
|
|
||||||
@tickets_needing_names = []
|
|
||||||
@cart_data.each do |ticket_type_id, item|
|
|
||||||
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
|
||||||
next unless ticket_type
|
|
||||||
|
|
||||||
quantity = item["quantity"].to_i
|
|
||||||
next if quantity <= 0
|
|
||||||
|
|
||||||
if ticket_type.requires_id
|
|
||||||
quantity.times do |i|
|
|
||||||
@tickets_needing_names << {
|
|
||||||
ticket_type_id: ticket_type.id,
|
|
||||||
ticket_type_name: ticket_type.name,
|
|
||||||
index: i
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Process submitted names and create Stripe session
|
|
||||||
def process_names
|
|
||||||
cart_data = session[:pending_cart] || {}
|
|
||||||
|
|
||||||
if cart_data.empty?
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Store names in session for later use
|
|
||||||
session[:ticket_names] = params[:ticket_names] if params[:ticket_names]
|
|
||||||
|
|
||||||
# Proceed to payment
|
|
||||||
process_payment(cart_data)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handle successful payment
|
|
||||||
def payment_success
|
|
||||||
session_id = params[:session_id]
|
|
||||||
event_id = params[:event_id]
|
|
||||||
|
|
||||||
begin
|
|
||||||
session = Stripe::Checkout::Session.retrieve(session_id)
|
|
||||||
|
|
||||||
if session.payment_status == "paid"
|
|
||||||
# Create tickets
|
|
||||||
@event = Event.find(event_id)
|
|
||||||
order_items = JSON.parse(session.metadata["order_items"])
|
|
||||||
@tickets = []
|
|
||||||
|
|
||||||
# Get names from session if they exist
|
|
||||||
ticket_names = session[:ticket_names] || {}
|
|
||||||
|
|
||||||
order_items.each do |item|
|
|
||||||
ticket_type = TicketType.find(item["ticket_type_id"])
|
|
||||||
item["quantity"].times do |i|
|
|
||||||
# Get names if this ticket type requires them
|
|
||||||
first_name = nil
|
|
||||||
last_name = nil
|
|
||||||
|
|
||||||
if ticket_type.requires_id
|
|
||||||
name_key = "#{ticket_type.id}_#{i}"
|
|
||||||
names = ticket_names[name_key] || {}
|
|
||||||
first_name = names["first_name"]
|
|
||||||
last_name = names["last_name"]
|
|
||||||
end
|
|
||||||
|
|
||||||
ticket = Ticket.create!(
|
|
||||||
user: current_user,
|
|
||||||
ticket_type: ticket_type,
|
|
||||||
status: "active",
|
|
||||||
first_name: first_name,
|
|
||||||
last_name: last_name
|
|
||||||
)
|
|
||||||
@tickets << ticket
|
|
||||||
|
|
||||||
# Send confirmation email for each ticket
|
|
||||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Clear session data
|
|
||||||
session.delete(:pending_cart)
|
|
||||||
session.delete(:ticket_names)
|
|
||||||
|
|
||||||
render "payment_success"
|
|
||||||
else
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
|
|
||||||
end
|
|
||||||
rescue Stripe::StripeError => e
|
|
||||||
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{e.message}"
|
|
||||||
rescue => e
|
|
||||||
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Download ticket PDF
|
|
||||||
def download_ticket
|
|
||||||
@ticket = current_user.tickets.find(params[:ticket_id])
|
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.pdf do
|
|
||||||
pdf = @ticket.to_pdf
|
|
||||||
send_data pdf,
|
|
||||||
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
|
|
||||||
type: "application/pdf",
|
|
||||||
disposition: "attachment"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Find and set the current event with eager-loaded associations
|
||||||
|
#
|
||||||
|
# Loads event with ticket types to avoid N+1 queries
|
||||||
|
# Raises ActiveRecord::RecordNotFound if event doesn't exist
|
||||||
def set_event
|
def set_event
|
||||||
@event = Event.find(params[:id])
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
end
|
|
||||||
|
|
||||||
# Process payment and create Stripe session
|
|
||||||
def process_payment(cart_data)
|
|
||||||
# Create order items from cart
|
|
||||||
line_items = []
|
|
||||||
order_items = []
|
|
||||||
total_amount = 0
|
|
||||||
|
|
||||||
cart_data.each do |ticket_type_id, item|
|
|
||||||
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
|
||||||
next unless ticket_type
|
|
||||||
|
|
||||||
quantity = item["quantity"].to_i
|
|
||||||
next if quantity <= 0
|
|
||||||
|
|
||||||
# Check availability
|
|
||||||
available = ticket_type.quantity - ticket_type.tickets.count
|
|
||||||
if quantity > available
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Pas assez de billets disponibles pour #{ticket_type.name}"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create Stripe line item
|
|
||||||
line_items << {
|
|
||||||
price_data: {
|
|
||||||
currency: "eur",
|
|
||||||
product_data: {
|
|
||||||
name: "#{@event.name} - #{ticket_type.name}",
|
|
||||||
description: ticket_type.description
|
|
||||||
},
|
|
||||||
unit_amount: ticket_type.price_cents
|
|
||||||
},
|
|
||||||
quantity: quantity
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store for ticket creation
|
|
||||||
order_items << {
|
|
||||||
ticket_type_id: ticket_type.id,
|
|
||||||
ticket_type_name: ticket_type.name,
|
|
||||||
quantity: quantity,
|
|
||||||
price_cents: ticket_type.price_cents
|
|
||||||
}
|
|
||||||
|
|
||||||
total_amount += ticket_type.price_cents * quantity
|
|
||||||
end
|
|
||||||
|
|
||||||
if order_items.empty?
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Commande invalide"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
# Create Stripe Checkout Session
|
|
||||||
session = Stripe::Checkout::Session.create({
|
|
||||||
payment_method_types: [ "card" ],
|
|
||||||
line_items: line_items,
|
|
||||||
mode: "payment",
|
|
||||||
success_url: payment_success_url(event_id: @event.id, session_id: "{CHECKOUT_SESSION_ID}"),
|
|
||||||
cancel_url: event_url(@event.slug, @event),
|
|
||||||
customer_email: current_user.email,
|
|
||||||
metadata: {
|
|
||||||
event_id: @event.id,
|
|
||||||
user_id: current_user.id,
|
|
||||||
order_items: order_items.to_json
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
redirect_to session.url, allow_other_host: true
|
|
||||||
rescue Stripe::StripeError => e
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
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
|
||||||
310
app/controllers/orders_controller.rb
Normal file
310
app/controllers/orders_controller.rb
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Handle order management and checkout process
|
||||||
|
#
|
||||||
|
# This controller manages the order lifecycle from checkout to payment completion
|
||||||
|
# Orders group multiple tickets together for better transaction management
|
||||||
|
class OrdersController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt, :invoice ]
|
||||||
|
before_action :set_event, only: [ :new, :create ]
|
||||||
|
|
||||||
|
# Display new order form with name collection
|
||||||
|
#
|
||||||
|
# On this page user can see order summary and complete the tickets details
|
||||||
|
# (first name and last name) for each ticket ordered
|
||||||
|
def new
|
||||||
|
@cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||||
|
|
||||||
|
if @cart_data.empty?
|
||||||
|
redirect_to event_path(@event.slug, @event), alert: "Veuillez d'abord sélectionner vos billets sur la page de l'événement"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build list of tickets requiring names
|
||||||
|
@tickets_needing_names = []
|
||||||
|
@cart_data.each do |ticket_type_id, item|
|
||||||
|
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
||||||
|
next unless ticket_type
|
||||||
|
|
||||||
|
quantity = item["quantity"].to_i
|
||||||
|
next if quantity <= 0
|
||||||
|
|
||||||
|
quantity.times do |i|
|
||||||
|
@tickets_needing_names << {
|
||||||
|
ticket_type_id: ticket_type.id,
|
||||||
|
ticket_type_name: ticket_type.name,
|
||||||
|
ticket_type_price: ticket_type.price_cents,
|
||||||
|
index: i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new order with tickets
|
||||||
|
#
|
||||||
|
# Here a new order is created with associated tickets in draft state.
|
||||||
|
# When user is ready they can proceed to payment via the order checkout
|
||||||
|
def create
|
||||||
|
@cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||||
|
|
||||||
|
if @cart_data.empty?
|
||||||
|
redirect_to event_path(@event.slug, @event), alert: "Aucun billet sélectionné"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
success = false
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@order = current_user.orders.create!(event: @event, status: "draft")
|
||||||
|
|
||||||
|
order_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
||||||
|
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
||||||
|
|
||||||
|
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
||||||
|
|
||||||
|
ticket = @order.tickets.build(
|
||||||
|
ticket_type: ticket_type,
|
||||||
|
first_name: ticket_attrs[:first_name],
|
||||||
|
last_name: ticket_attrs[:last_name],
|
||||||
|
status: "draft"
|
||||||
|
)
|
||||||
|
|
||||||
|
unless ticket.save
|
||||||
|
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if @order.tickets.present?
|
||||||
|
@order.calculate_total!
|
||||||
|
success = true
|
||||||
|
else
|
||||||
|
flash[:alert] = "Aucun billet valide créé"
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle redirects outside transaction
|
||||||
|
if success
|
||||||
|
session[:draft_order_id] = @order.id
|
||||||
|
session.delete(:pending_cart)
|
||||||
|
redirect_to checkout_order_path(@order)
|
||||||
|
else
|
||||||
|
redirect_to event_order_new_path(@event.slug, @event.id)
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||||
|
flash[:alert] = "Une erreur est survenue: #{error_message}"
|
||||||
|
redirect_to event_order_new_path(@event.slug, @event.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display all user orders
|
||||||
|
def index
|
||||||
|
@orders = current_user.orders.includes(:event, tickets: :ticket_type)
|
||||||
|
.where(status: [ "paid", "completed" ])
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.page(params[:page])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display order summary
|
||||||
|
def show
|
||||||
|
@tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display payment page for an order
|
||||||
|
#
|
||||||
|
# Display a summary of all tickets in the order and permit user
|
||||||
|
# to proceed to payment via Stripe
|
||||||
|
def checkout
|
||||||
|
# Handle expired orders
|
||||||
|
if @order.expired?
|
||||||
|
@order.expire_if_overdue!
|
||||||
|
return redirect_to event_path(@order.event.slug, @order.event),
|
||||||
|
alert: "Votre commande a expiré. Veuillez recommencer."
|
||||||
|
end
|
||||||
|
|
||||||
|
@tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
@total_amount = @order.total_amount_cents
|
||||||
|
@expiring_soon = @order.expiring_soon?
|
||||||
|
|
||||||
|
# Create Stripe checkout session if Stripe is configured
|
||||||
|
if Rails.application.config.stripe[:secret_key].present?
|
||||||
|
begin
|
||||||
|
@checkout_session = create_stripe_session
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||||
|
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
|
||||||
|
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Increment payment attempt - called via AJAX when user clicks pay button
|
||||||
|
def increment_payment_attempt
|
||||||
|
@order.increment_payment_attempt!
|
||||||
|
render json: { success: true, attempts: @order.payment_attempts }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Allow users to retry payment for failed/cancelled payments
|
||||||
|
def retry_payment
|
||||||
|
unless @order.can_retry_payment?
|
||||||
|
redirect_to event_path(@order.event.slug, @order.event),
|
||||||
|
alert: "Cette commande ne peut plus être payée"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# For POST requests, increment the payment attempt counter
|
||||||
|
if request.post?
|
||||||
|
@order.increment_payment_attempt!
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to checkout_order_path(@order)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display invoice for an order
|
||||||
|
def invoice
|
||||||
|
unless @order.status == "paid" || @order.status == "completed"
|
||||||
|
redirect_to order_path(@order), alert: "La facture n'est disponible qu'après le paiement de la commande"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
|
||||||
|
# Get the Stripe invoice if it exists
|
||||||
|
begin
|
||||||
|
@stripe_invoice_id = @order.create_stripe_invoice!
|
||||||
|
@stripe_invoice_pdf_url = @order.stripe_invoice_pdf_url if @stripe_invoice_id
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to retrieve or create Stripe invoice for order #{@order.id}: #{e.message}"
|
||||||
|
@stripe_invoice_id = nil
|
||||||
|
@stripe_invoice_pdf_url = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle successful payment
|
||||||
|
def payment_success
|
||||||
|
session_id = params[:session_id]
|
||||||
|
|
||||||
|
# Check if Stripe is properly configured
|
||||||
|
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
||||||
|
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
||||||
|
|
||||||
|
unless stripe_configured
|
||||||
|
redirect_to root_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
|
||||||
|
|
||||||
|
if stripe_session.payment_status == "paid"
|
||||||
|
# Get order_id from session metadata
|
||||||
|
order_id = stripe_session.metadata["order_id"]
|
||||||
|
|
||||||
|
unless order_id.present?
|
||||||
|
redirect_to dashboard_path, alert: "Informations de commande manquantes"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find and update the order
|
||||||
|
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
|
||||||
|
@order.mark_as_paid!
|
||||||
|
|
||||||
|
# Schedule Stripe invoice generation in background
|
||||||
|
# This creates accounting records without blocking the payment success flow
|
||||||
|
begin
|
||||||
|
StripeInvoiceGenerationJob.perform_later(@order.id)
|
||||||
|
Rails.logger.info "Scheduled Stripe invoice generation for order #{@order.id}"
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to schedule invoice generation for order #{@order.id}: #{e.message}"
|
||||||
|
# Don't fail the payment process due to job scheduling issues
|
||||||
|
end
|
||||||
|
|
||||||
|
# Email confirmation is handled by the order model's mark_as_paid! method
|
||||||
|
# to avoid duplicate emails
|
||||||
|
|
||||||
|
# Clear session data
|
||||||
|
session.delete(:pending_cart)
|
||||||
|
session.delete(:ticket_names)
|
||||||
|
session.delete(:draft_order_id)
|
||||||
|
|
||||||
|
render "payment_success"
|
||||||
|
else
|
||||||
|
redirect_to dashboard_path, alert: "Le paiement n'a pas été complété avec succès"
|
||||||
|
end
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||||
|
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}"
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||||
|
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
|
||||||
|
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{error_message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle payment failure/cancellation
|
||||||
|
def payment_cancel
|
||||||
|
order_id = params[:order_id] || session[:draft_order_id]
|
||||||
|
|
||||||
|
if order_id.present?
|
||||||
|
order = current_user.orders.find_by(id: order_id, status: "draft")
|
||||||
|
|
||||||
|
if order&.can_retry_payment?
|
||||||
|
redirect_to checkout_order_path(order),
|
||||||
|
alert: "Le paiement a été annulé. Vous pouvez réessayer."
|
||||||
|
else
|
||||||
|
session.delete(:draft_order_id)
|
||||||
|
redirect_to root_path, alert: "Le paiement a été annulé et votre commande a expiré."
|
||||||
|
end
|
||||||
|
else
|
||||||
|
redirect_to root_path, alert: "Le paiement a été annulé"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_order
|
||||||
|
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to root_path, alert: "Commande non trouvée"
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_event
|
||||||
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to events_path, alert: "Événement non trouvé"
|
||||||
|
end
|
||||||
|
|
||||||
|
def order_params
|
||||||
|
params.permit(tickets_attributes: [ :ticket_type_id, :first_name, :last_name ])
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_stripe_session
|
||||||
|
line_items = @order.tickets.map do |ticket|
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: "eur",
|
||||||
|
product_data: {
|
||||||
|
name: "#{@order.event.name} - #{ticket.ticket_type.name}",
|
||||||
|
description: ticket.ticket_type.description
|
||||||
|
},
|
||||||
|
unit_amount: ticket.price_cents
|
||||||
|
},
|
||||||
|
quantity: 1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# No service fee added to customer; deducted from promoter payout
|
||||||
|
|
||||||
|
Stripe::Checkout::Session.create(
|
||||||
|
payment_method_types: [ "card" ],
|
||||||
|
line_items: line_items,
|
||||||
|
mode: "payment",
|
||||||
|
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
cancel_url: order_payment_cancel_url,
|
||||||
|
metadata: {
|
||||||
|
order_id: @order.id,
|
||||||
|
user_id: current_user.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,38 +1,100 @@
|
|||||||
# Controller for static pages and user dashboard
|
# Controller for static pages and user dashboard
|
||||||
# Handles basic page rendering and user-specific content
|
# Handles basic page rendering and user-specific content
|
||||||
class PagesController < ApplicationController
|
class PagesController < ApplicationController
|
||||||
# Skip authentication for public pages
|
|
||||||
# skip_before_action :authenticate_user!, only: [ :home ]
|
|
||||||
before_action :authenticate_user!, only: [ :dashboard ]
|
before_action :authenticate_user!, only: [ :dashboard ]
|
||||||
|
|
||||||
# Homepage showing featured events
|
# Homepage showing featured events as landing page
|
||||||
|
#
|
||||||
|
# Display homepage with featured events and site metrics for all users
|
||||||
def home
|
def home
|
||||||
@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
|
||||||
return 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.tickets.joins(:ticket_type, :event).where(events: { state: :published }).count
|
@user_orders = current_user.orders.includes(:event, tickets: :ticket_type)
|
||||||
@events_today = Event.published.where("DATE(start_time) = ?", Date.current).count
|
.where(status: [ "paid", "completed" ])
|
||||||
@events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count
|
.order(created_at: :desc)
|
||||||
@upcoming_events = Event.published.upcoming.count
|
.limit(10)
|
||||||
|
|
||||||
# User's booked events
|
# Draft orders that can be retried
|
||||||
@user_booked_events = Event.joins(ticket_types: :tickets)
|
@draft_orders = current_user.orders.includes(tickets: [ :ticket_type, :event ])
|
||||||
.where(tickets: { user: current_user, status: 'active' })
|
.can_retry_payment
|
||||||
.distinct
|
.order(:expires_at)
|
||||||
.limit(5)
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
129
app/controllers/promoter/events_controller.rb
Normal file
129
app/controllers/promoter/events_controller.rb
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Promoter Events Controller
|
||||||
|
#
|
||||||
|
# Handles event management for promoters (event organizers)
|
||||||
|
# Allows promoters to create, edit, delete and manage their events
|
||||||
|
class Promoter::EventsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :ensure_can_manage_events!
|
||||||
|
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ]
|
||||||
|
|
||||||
|
# Display all events for the current promoter
|
||||||
|
def index
|
||||||
|
@events = current_user.events.order(created_at: :desc).page(params[:page]).per(10)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display a specific event for the promoter
|
||||||
|
def show
|
||||||
|
# Event is set by set_event callback
|
||||||
|
end
|
||||||
|
|
||||||
|
# Show form to create a new event
|
||||||
|
def new
|
||||||
|
@event = current_user.events.build
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new event
|
||||||
|
def create
|
||||||
|
@event = current_user.events.build(event_params)
|
||||||
|
|
||||||
|
if @event.save
|
||||||
|
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Show form to edit an existing event
|
||||||
|
def edit
|
||||||
|
# Event is set by set_event callback
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update an existing event
|
||||||
|
def update
|
||||||
|
if @event.update(event_params)
|
||||||
|
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete an event
|
||||||
|
def destroy
|
||||||
|
@event.destroy
|
||||||
|
redirect_to promoter_events_path, notice: "Event supprimé avec succès!"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Publish an event (make it visible to public)
|
||||||
|
def publish
|
||||||
|
if @event.draft?
|
||||||
|
@event.update(state: :published)
|
||||||
|
redirect_to promoter_event_path(@event), notice: "Event publié avec succès!"
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être publié."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unpublish an event (make it draft)
|
||||||
|
def unpublish
|
||||||
|
if @event.published?
|
||||||
|
@event.update(state: :draft)
|
||||||
|
redirect_to promoter_event_path(@event), notice: "Event dépublié avec succès!"
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être dépublié."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cancel an event
|
||||||
|
def cancel
|
||||||
|
if @event.published?
|
||||||
|
@event.update(state: :canceled)
|
||||||
|
redirect_to promoter_event_path(@event), notice: "Event annulé avec succès!"
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être annulé."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark event as sold out
|
||||||
|
def mark_sold_out
|
||||||
|
if @event.published?
|
||||||
|
@event.update(state: :sold_out)
|
||||||
|
redirect_to promoter_event_path(@event), notice: "Event marqué comme complet!"
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme complet."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Duplicate an event and all its ticket types
|
||||||
|
def duplicate
|
||||||
|
clone_ticket_types = params[:clone_ticket_types] == "true"
|
||||||
|
@new_event = @event.duplicate(clone_ticket_types: clone_ticket_types)
|
||||||
|
|
||||||
|
if @new_event
|
||||||
|
redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié."
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_can_manage_events!
|
||||||
|
unless current_user.can_manage_events?
|
||||||
|
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_event
|
||||||
|
@event = current_user.events.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
|
||||||
|
end
|
||||||
|
|
||||||
|
def event_params
|
||||||
|
params.require(:event).permit(
|
||||||
|
:name, :slug, :description, :image,
|
||||||
|
:venue_name, :venue_address, :latitude, :longitude,
|
||||||
|
:start_time, :end_time, :featured, :allow_booking_during_event
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
104
app/controllers/promoter/ticket_types_controller.rb
Normal file
104
app/controllers/promoter/ticket_types_controller.rb
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Promoter Ticket Types Controller
|
||||||
|
#
|
||||||
|
# Handles ticket type (bundle) management for promoters
|
||||||
|
# Allows promoters to create, edit, delete and manage ticket types for their events
|
||||||
|
class Promoter::TicketTypesController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :ensure_can_manage_events!
|
||||||
|
before_action :set_event
|
||||||
|
before_action :set_ticket_type, only: [ :show, :edit, :update, :destroy ]
|
||||||
|
|
||||||
|
# Display all ticket types for an event
|
||||||
|
def index
|
||||||
|
@ticket_types = @event.ticket_types.order(:created_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display a specific ticket type
|
||||||
|
def show
|
||||||
|
# Ticket type is set by set_ticket_type callback
|
||||||
|
end
|
||||||
|
|
||||||
|
# Show form to create a new ticket type
|
||||||
|
def new
|
||||||
|
@ticket_type = @event.ticket_types.build
|
||||||
|
# Set default values
|
||||||
|
@ticket_type.sale_start_at = Time.current
|
||||||
|
@ticket_type.sale_end_at = @event.start_time || 1.week.from_now
|
||||||
|
@ticket_type.requires_id = false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new ticket type
|
||||||
|
def create
|
||||||
|
@ticket_type = @event.ticket_types.build(ticket_type_params)
|
||||||
|
|
||||||
|
if @ticket_type.save
|
||||||
|
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet créé avec succès!"
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Show form to edit an existing ticket type
|
||||||
|
def edit
|
||||||
|
# Ticket type is set by set_ticket_type callback
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update an existing ticket type
|
||||||
|
def update
|
||||||
|
if @ticket_type.update(ticket_type_params)
|
||||||
|
redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet mis à jour avec succès!"
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete a ticket type
|
||||||
|
def destroy
|
||||||
|
if @ticket_type.tickets.any?
|
||||||
|
redirect_to promoter_event_ticket_types_path(@event), alert: "Impossible de supprimer ce type de billet car des billets ont déjà été vendus."
|
||||||
|
else
|
||||||
|
@ticket_type.destroy
|
||||||
|
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet supprimé avec succès!"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Duplicate an existing ticket type
|
||||||
|
def duplicate
|
||||||
|
original = @event.ticket_types.find(params[:id])
|
||||||
|
@ticket_type = original.dup
|
||||||
|
@ticket_type.name = "#{original.name} (Copie)"
|
||||||
|
|
||||||
|
if @ticket_type.save
|
||||||
|
redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet dupliqué avec succès!"
|
||||||
|
else
|
||||||
|
redirect_to promoter_event_ticket_types_path(@event), alert: "Erreur lors de la duplication."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_can_manage_events!
|
||||||
|
unless current_user.can_manage_events?
|
||||||
|
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_event
|
||||||
|
@event = current_user.events.find(params[:event_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_ticket_type
|
||||||
|
@ticket_type = @event.ticket_types.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to promoter_event_ticket_types_path(@event), alert: "Type de billet non trouvé."
|
||||||
|
end
|
||||||
|
|
||||||
|
def ticket_type_params
|
||||||
|
params.require(:ticket_type).permit(
|
||||||
|
:name, :description, :price_euros, :quantity,
|
||||||
|
:sale_start_at, :sale_end_at, :minimum_age, :requires_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
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
|
||||||
115
app/controllers/tickets_controller.rb
Normal file
115
app/controllers/tickets_controller.rb
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Legacy tickets controller - redirects to new order system
|
||||||
|
#
|
||||||
|
# This controller now primarily handles legacy redirects and backward compatibility
|
||||||
|
# Most ticket creation functionality has been moved to OrdersController
|
||||||
|
class TicketsController < ApplicationController
|
||||||
|
before_action :authenticate_user!, only: [ :payment_success, :payment_cancel, :show, :download ]
|
||||||
|
before_action :set_event, only: [ :checkout, :retry_payment ]
|
||||||
|
|
||||||
|
|
||||||
|
# Redirect to order-based checkout
|
||||||
|
def checkout
|
||||||
|
# Check for draft order
|
||||||
|
if session[:draft_order_id].present?
|
||||||
|
order = current_user.orders.find_by(id: session[:draft_order_id], status: "draft")
|
||||||
|
if order.present?
|
||||||
|
redirect_to order_checkout_path(order)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# No order found
|
||||||
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
|
redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redirect to order-based payment success
|
||||||
|
def payment_success
|
||||||
|
redirect_to order_payment_success_path(session_id: params[:session_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redirect to order-based payment cancel
|
||||||
|
def payment_cancel
|
||||||
|
redirect_to order_payment_cancel_path
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redirect retry payment to order system
|
||||||
|
def retry_payment
|
||||||
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
|
|
||||||
|
# Look for draft order for this event
|
||||||
|
order = current_user.orders.find_by(event: @event, status: "draft")
|
||||||
|
|
||||||
|
if order&.can_retry_payment?
|
||||||
|
redirect_to retry_payment_order_path(order)
|
||||||
|
else
|
||||||
|
redirect_to event_path(@event.slug, @event),
|
||||||
|
alert: "Aucune commande disponible pour un nouveau paiement"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display ticket details
|
||||||
|
def show
|
||||||
|
# Find ticket by qr code id
|
||||||
|
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
|
||||||
|
.find_by(tickets: { qr_code: params[:qr_code] })
|
||||||
|
|
||||||
|
if @ticket.nil?
|
||||||
|
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@event = @ticket.event
|
||||||
|
@order = @ticket.order
|
||||||
|
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Download PDF ticket - only accessible by ticket owner
|
||||||
|
# User must be authenticated to download ticket
|
||||||
|
# TODO: change ID to an unique identifier (UUID)
|
||||||
|
def download
|
||||||
|
# Find ticket by qr code id
|
||||||
|
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
|
||||||
|
.find_by(tickets: { qr_code: params[:qr_code] })
|
||||||
|
|
||||||
|
if @ticket.nil?
|
||||||
|
redirect_to dashboard_path, alert: "Billet non trouvé ou vous n'avez pas l'autorisation d'accéder à ce billet"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate PDF
|
||||||
|
pdf_content = @ticket.to_pdf
|
||||||
|
|
||||||
|
# Send PDF as download
|
||||||
|
send_data pdf_content,
|
||||||
|
filename: "ticket_#{@ticket.id}_#{@ticket.event.name.parameterize}.pdf",
|
||||||
|
type: "application/pdf",
|
||||||
|
disposition: "attachment"
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error generating ticket PDF: #{e.message}"
|
||||||
|
redirect_to dashboard_path, alert: "Erreur lors de la génération du billet"
|
||||||
|
end
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_event
|
||||||
|
event_id = params[:id] || session[:event_id]
|
||||||
|
|
||||||
|
Rails.logger.debug "TicketsController#set_event - params[:id]: #{params[:id].inspect}, session[:event_id]: #{session[:event_id].inspect}"
|
||||||
|
|
||||||
|
unless event_id
|
||||||
|
Rails.logger.error "TicketsController#set_event - No event ID found"
|
||||||
|
redirect_to events_path, alert: "Aucun événement spécifié"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@event = Event.includes(:ticket_types).find(event_id)
|
||||||
|
Rails.logger.debug "TicketsController#set_event - Found event: #{@event.id} - #{@event.name}"
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
Rails.logger.error "TicketsController#set_event - Event not found with ID: #{event_id}"
|
||||||
|
redirect_to events_path, alert: "Événement non trouvé"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
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
|
||||||
|
|
||||||
# Include flash message helpers
|
# Include flash message helpers
|
||||||
include FlashMessagesHelper
|
include FlashMessagesHelper
|
||||||
|
|
||||||
|
# Include Stripe helper
|
||||||
|
include StripeHelper
|
||||||
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"
|
||||||
|
|||||||
82
app/helpers/lucide_helper.rb
Normal file
82
app/helpers/lucide_helper.rb
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
module LucideHelper
|
||||||
|
# Create a Lucide icon element
|
||||||
|
#
|
||||||
|
# @param name [String] The name of the Lucide icon
|
||||||
|
# @param options [Hash] Additional options
|
||||||
|
# @option options [String] :class Additional CSS classes
|
||||||
|
# @option options [String] :size Size class (e.g., 'w-4 h-4', 'w-6 h-6')
|
||||||
|
# @option options [Hash] :data Additional data attributes
|
||||||
|
#
|
||||||
|
# @return [String] HTML string for the icon
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# lucide_icon('user')
|
||||||
|
# lucide_icon('check-circle', class: 'text-green-500', size: 'w-5 h-5')
|
||||||
|
# lucide_icon('menu', data: { action: 'click->header#toggleMenu' })
|
||||||
|
def lucide_icon(name, options = {})
|
||||||
|
css_classes = [ "lucide-icon" ]
|
||||||
|
css_classes << options[:size] if options[:size]
|
||||||
|
css_classes << options[:class] if options[:class]
|
||||||
|
|
||||||
|
data_attributes = { lucide: name }
|
||||||
|
data_attributes.merge!(options[:data]) if options[:data]
|
||||||
|
|
||||||
|
content_tag :i, "",
|
||||||
|
class: css_classes.join(" "),
|
||||||
|
data: data_attributes,
|
||||||
|
**options.except(:class, :size, :data)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a button with a Lucide icon
|
||||||
|
#
|
||||||
|
# @param name [String] The name of the Lucide icon
|
||||||
|
# @param options [Hash] Button options
|
||||||
|
# @option options [String] :text Button text (optional)
|
||||||
|
# @option options [String] :class Additional CSS classes for button
|
||||||
|
# @option options [String] :icon_class Additional CSS classes for icon
|
||||||
|
# @option options [String] :icon_size Size class for icon
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# lucide_button('plus', text: 'Add Item', class: 'btn btn-primary')
|
||||||
|
# lucide_button('trash-2', class: 'btn-danger', data: { confirm: 'Are you sure?' })
|
||||||
|
def lucide_button(name, options = {})
|
||||||
|
text = options.delete(:text)
|
||||||
|
icon_class = options.delete(:icon_class)
|
||||||
|
icon_size = options.delete(:icon_size) || "w-4 h-4"
|
||||||
|
|
||||||
|
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
||||||
|
|
||||||
|
content = if text.present?
|
||||||
|
safe_join([ icon, " ", text ])
|
||||||
|
else
|
||||||
|
icon
|
||||||
|
end
|
||||||
|
|
||||||
|
content_tag :button, content, options
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a link with a Lucide icon
|
||||||
|
#
|
||||||
|
# @param name [String] The name of the Lucide icon
|
||||||
|
# @param url [String] The URL for the link
|
||||||
|
# @param options [Hash] Link options
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# lucide_link('edit', edit_user_path(user), text: 'Edit')
|
||||||
|
# lucide_link('external-link', 'https://example.com', text: 'Visit', target: '_blank')
|
||||||
|
def lucide_link(name, url, options = {})
|
||||||
|
text = options.delete(:text)
|
||||||
|
icon_class = options.delete(:icon_class)
|
||||||
|
icon_size = options.delete(:icon_size) || "w-4 h-4"
|
||||||
|
|
||||||
|
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
||||||
|
|
||||||
|
content = if text.present?
|
||||||
|
safe_join([ icon, " ", text ])
|
||||||
|
else
|
||||||
|
icon
|
||||||
|
end
|
||||||
|
|
||||||
|
link_to content, url, options
|
||||||
|
end
|
||||||
|
end
|
||||||
2
app/helpers/onboarding_helper.rb
Normal file
2
app/helpers/onboarding_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module OnboardingHelper
|
||||||
|
end
|
||||||
17
app/helpers/stripe_helper.rb
Normal file
17
app/helpers/stripe_helper.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
module StripeHelper
|
||||||
|
# Safely call Stripe methods with error handling
|
||||||
|
def safe_stripe_call(&block)
|
||||||
|
# Check if Stripe is properly configured
|
||||||
|
return nil unless Rails.application.config.stripe[:secret_key].present?
|
||||||
|
|
||||||
|
# Stripe is now initialized at application startup
|
||||||
|
Rails.logger.debug "Using globally initialized Stripe"
|
||||||
|
|
||||||
|
begin
|
||||||
|
yield if block_given?
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
Rails.logger.error "Stripe Error: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
2
app/helpers/tickets_helper.rb
Normal file
2
app/helpers/tickets_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module TicketsHelper
|
||||||
|
end
|
||||||
@@ -1,3 +1,23 @@
|
|||||||
// Entry point for the build script in your package.json
|
// Entry point for the build script in your package.json
|
||||||
|
// This file initializes the Rails application with Turbo and Stimulus controllers
|
||||||
|
|
||||||
|
// Import Turbo Rails for SPA-like navigation
|
||||||
import "@hotwired/turbo-rails";
|
import "@hotwired/turbo-rails";
|
||||||
|
|
||||||
|
// Import all Stimulus controllers
|
||||||
import "./controllers";
|
import "./controllers";
|
||||||
|
|
||||||
|
// Import and initialize Lucide icons globally
|
||||||
|
import { createIcons, icons } from 'lucide';
|
||||||
|
|
||||||
|
// Initialize icons globally
|
||||||
|
function initializeLucideIcons() {
|
||||||
|
createIcons({ icons });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on initial page load
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeLucideIcons);
|
||||||
|
|
||||||
|
// Run on Turbo navigation (Rails 7+ SPA behavior)
|
||||||
|
document.addEventListener('turbo:render', initializeLucideIcons);
|
||||||
|
document.addEventListener('turbo:frame-render', initializeLucideIcons);
|
||||||
|
|||||||
@@ -1,55 +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"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"bg-purple text-purple-foreground shadow-xs hover:bg-purple/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
||||||
outline:
|
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-purple underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
||||||
icon: "size-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
// Main Stimulus application controller
|
||||||
|
// Initializes the Stimulus framework and makes it available globally
|
||||||
import { Application } from "@hotwired/stimulus";
|
import { Application } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Create and start the Stimulus application
|
||||||
const application = Application.start();
|
const application = Application.start();
|
||||||
|
|
||||||
// Configure Stimulus development experience
|
// Configure Stimulus development experience
|
||||||
|
// Set to false in production to avoid unnecessary logging
|
||||||
application.debug = false;
|
application.debug = false;
|
||||||
|
|
||||||
|
// Make Stimulus globally available for debugging purposes
|
||||||
window.Stimulus = application;
|
window.Stimulus = application;
|
||||||
|
|
||||||
// import Alpine from 'alpinejs';
|
// Configure Alpine js (commented out as it's not currently used)
|
||||||
|
// import Alpine from "alpinejs";
|
||||||
// window.Alpine = Alpine;
|
// window.Alpine = Alpine;
|
||||||
// Alpine.start();
|
// Alpine.start();
|
||||||
|
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,41 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Counter controller for animating number increments
|
||||||
|
// Used for statistics and numerical displays that animate when they come into view
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
// Define controller values with defaults
|
||||||
static values = {
|
static values = {
|
||||||
target: { type: Number, default: 0 },
|
target: { type: Number, default: 0 }, // Target number to count to
|
||||||
decimal: { type: Boolean, default: false },
|
decimal: { type: Boolean, default: false }, // Whether to display decimal values
|
||||||
duration: { type: Number, default: 2000 }
|
duration: { type: Number, default: 2000 } // Animation duration in milliseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up the intersection observer when the controller connects
|
||||||
connect() {
|
connect() {
|
||||||
|
// Create an intersection observer to trigger animation when element is visible
|
||||||
this.observer = new IntersectionObserver((entries) => {
|
this.observer = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
|
// Start animation when element is 50% visible
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
this.animate()
|
this.animate()
|
||||||
|
// Stop observing after animation starts
|
||||||
this.observer.unobserve(this.element)
|
this.observer.unobserve(this.element)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, { threshold: 0.5 })
|
}, { threshold: 0.5 })
|
||||||
|
|
||||||
|
// Begin observing this element
|
||||||
this.observer.observe(this.element)
|
this.observer.observe(this.element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up the observer when the controller disconnects
|
||||||
disconnect() {
|
disconnect() {
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect()
|
this.observer.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animate the counter from 0 to the target value
|
||||||
animate() {
|
animate() {
|
||||||
// Find the target element with data-target-value
|
// Find the target element with data-target-value
|
||||||
const targetElement = this.element.querySelector('.stat-number');
|
const targetElement = this.element.querySelector('.stat-number');
|
||||||
@@ -37,15 +47,17 @@ export default class extends Controller {
|
|||||||
const startValue = 0;
|
const startValue = 0;
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// Update counter function using requestAnimationFrame for smooth animation
|
||||||
const updateCounter = (currentTime) => {
|
const updateCounter = (currentTime) => {
|
||||||
const elapsedTime = currentTime - startTime;
|
const elapsedTime = currentTime - startTime;
|
||||||
const progress = Math.min(elapsedTime / this.durationValue, 1);
|
const progress = Math.min(elapsedTime / this.durationValue, 1);
|
||||||
|
|
||||||
// Easing function for smooth animation
|
// Easing function for smooth animation (ease-out quartic)
|
||||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||||
|
|
||||||
let currentValue = startValue + (this.targetValue - startValue) * easeOutQuart;
|
let currentValue = startValue + (this.targetValue - startValue) * easeOutQuart;
|
||||||
|
|
||||||
|
// Format value based on decimal setting
|
||||||
if (this.decimalValue && this.targetValue < 10) {
|
if (this.decimalValue && this.targetValue < 10) {
|
||||||
currentValue = currentValue.toFixed(1);
|
currentValue = currentValue.toFixed(1);
|
||||||
} else {
|
} else {
|
||||||
@@ -55,9 +67,11 @@ export default class extends Controller {
|
|||||||
// Update only the text content of the target element
|
// Update only the text content of the target element
|
||||||
targetElement.textContent = currentValue;
|
targetElement.textContent = currentValue;
|
||||||
|
|
||||||
|
// Continue animation until complete
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
requestAnimationFrame(updateCounter);
|
requestAnimationFrame(updateCounter);
|
||||||
} else {
|
} else {
|
||||||
|
// Ensure final value is exactly the target
|
||||||
const finalValue = this.decimalValue && this.targetValue < 10
|
const finalValue = this.decimalValue && this.targetValue < 10
|
||||||
? this.targetValue.toFixed(1)
|
? this.targetValue.toFixed(1)
|
||||||
: this.targetValue;
|
: this.targetValue;
|
||||||
@@ -65,6 +79,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the animation
|
||||||
requestAnimationFrame(updateCounter);
|
requestAnimationFrame(updateCounter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
667
app/javascript/controllers/event_form_controller.js
Normal file
667
app/javascript/controllers/event_form_controller.js
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import slug from 'slug'
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
|
||||||
|
static values = {
|
||||||
|
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
||||||
|
}
|
||||||
|
|
||||||
|
static lastGeocodingRequest = 0
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.geocodeTimeout = null
|
||||||
|
this.isManualGeocodingInProgress = false
|
||||||
|
|
||||||
|
// Initialize map links if we have an address and coordinates already exist
|
||||||
|
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
|
||||||
|
this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||||
|
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||||
|
this.updateMapLinks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.geocodeTimeout) {
|
||||||
|
clearTimeout(this.geocodeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug from name
|
||||||
|
generateSlug() {
|
||||||
|
const name = this.nameTarget.value
|
||||||
|
|
||||||
|
this.slugTarget.value = slug(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle address changes with debounced geocoding
|
||||||
|
addressChanged() {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (this.geocodeTimeout) {
|
||||||
|
clearTimeout(this.geocodeTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.addressTarget.value.trim()
|
||||||
|
|
||||||
|
if (!address) {
|
||||||
|
this.clearCoordinates()
|
||||||
|
this.clearMapLinks()
|
||||||
|
this.hideGeocodingSpinner()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show spinner after a brief delay to avoid flickering for very short typing
|
||||||
|
const showSpinnerTimeout = setTimeout(() => {
|
||||||
|
this.showGeocodingSpinner()
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
// Debounce geocoding to avoid too many API calls
|
||||||
|
this.geocodeTimeout = setTimeout(async () => {
|
||||||
|
clearTimeout(showSpinnerTimeout) // Cancel spinner delay if still pending
|
||||||
|
this.showGeocodingSpinner() // Show spinner for sure now
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.geocodeAddressQuiet(address)
|
||||||
|
} finally {
|
||||||
|
this.hideGeocodingSpinner()
|
||||||
|
}
|
||||||
|
}, this.geocodeDelayValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's current location and reverse geocode to address
|
||||||
|
async getCurrentLocation() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
this.showLocationError("La géolocalisation n'est pas supportée par ce navigateur.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showGetCurrentLocationLoading()
|
||||||
|
this.showLocationLoading()
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 60000
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const position = await this.getCurrentPositionPromise(options)
|
||||||
|
const lat = position.coords.latitude
|
||||||
|
const lng = position.coords.longitude
|
||||||
|
|
||||||
|
// Set coordinates first
|
||||||
|
this.latitudeTarget.value = lat.toFixed(6)
|
||||||
|
this.longitudeTarget.value = lng.toFixed(6)
|
||||||
|
|
||||||
|
// Then reverse geocode to get address
|
||||||
|
const address = await this.reverseGeocode(lat, lng)
|
||||||
|
|
||||||
|
if (address) {
|
||||||
|
this.addressTarget.value = address
|
||||||
|
this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
|
||||||
|
} else {
|
||||||
|
this.showLocationSuccess("Position actuelle détectée!")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateMapLinks()
|
||||||
|
this.hideGetCurrentLocationLoading()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.hideGetCurrentLocationLoading()
|
||||||
|
this.hideLocationLoading()
|
||||||
|
let message = "Erreur lors de la récupération de la localisation."
|
||||||
|
|
||||||
|
switch(error.code) {
|
||||||
|
case error.PERMISSION_DENIED:
|
||||||
|
message = "L'accès à la localisation a été refusé."
|
||||||
|
break
|
||||||
|
case error.POSITION_UNAVAILABLE:
|
||||||
|
message = "Les informations de localisation ne sont pas disponibles."
|
||||||
|
break
|
||||||
|
case error.TIMEOUT:
|
||||||
|
message = "La demande de localisation a expiré."
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showLocationError(message)
|
||||||
|
} finally {
|
||||||
|
this.hideGetCurrentLocationLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise wrapper for geolocation
|
||||||
|
getCurrentPositionPromise(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(resolve, reject, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse geocode coordinates to get address
|
||||||
|
async reverseGeocode(lat, lng) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Reverse geocoding response:', data) // Debug log
|
||||||
|
|
||||||
|
if (data && data.display_name) {
|
||||||
|
return data.display_name
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Reverse geocoding failed:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview location - same as updating map links but with user feedback
|
||||||
|
previewLocation() {
|
||||||
|
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||||
|
this.showLocationError("Veuillez saisir une adresse pour la prévisualiser.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already have coordinates, just update map links
|
||||||
|
if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||||
|
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||||
|
this.updateMapLinks()
|
||||||
|
this.showLocationSuccess("Liens de carte mis à jour!")
|
||||||
|
} else {
|
||||||
|
// Otherwise geocode the address first
|
||||||
|
this.showPreviewLocationLoading()
|
||||||
|
this.geocodeAddress().finally(() => {
|
||||||
|
this.hidePreviewLocationLoading()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geocode address manually (with user feedback)
|
||||||
|
async geocodeAddress() {
|
||||||
|
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||||
|
this.showLocationError("Veuillez saisir une adresse.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.addressTarget.value.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isManualGeocodingInProgress = true
|
||||||
|
this.showLocationLoading()
|
||||||
|
const result = await this.performGeocode(address)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.latitudeTarget.value = result.lat
|
||||||
|
this.longitudeTarget.value = result.lng
|
||||||
|
this.updateMapLinks()
|
||||||
|
|
||||||
|
if (result.accuracy === 'exact') {
|
||||||
|
this.showLocationSuccess("Coordonnées exactes trouvées pour cette adresse!")
|
||||||
|
} else {
|
||||||
|
this.showLocationSuccess(`Coordonnées approximatives trouvées: ${result.display_name}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showLocationError("Erreur lors de la recherche de l'adresse.")
|
||||||
|
} finally {
|
||||||
|
this.isManualGeocodingInProgress = false
|
||||||
|
this.hideLocationLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geocode address quietly (no user feedback, for auto-geocoding)
|
||||||
|
async geocodeAddressQuiet(address) {
|
||||||
|
// Skip if address is too short or invalid
|
||||||
|
if (!address || address.length < 5) {
|
||||||
|
this.clearCoordinates()
|
||||||
|
this.clearMapLinks()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.performGeocode(address)
|
||||||
|
|
||||||
|
if (result && result.lat && result.lng) {
|
||||||
|
this.latitudeTarget.value = result.lat
|
||||||
|
this.longitudeTarget.value = result.lng
|
||||||
|
this.updateMapLinks()
|
||||||
|
console.log(`Auto-geocoded "${address}" to ${result.lat}, ${result.lng}`)
|
||||||
|
|
||||||
|
// Show success message based on accuracy
|
||||||
|
if (result.accuracy === 'exact') {
|
||||||
|
this.showGeocodingSuccess("Adresse géolocalisée avec précision", result.display_name)
|
||||||
|
} else {
|
||||||
|
this.showGeocodingSuccess("Adresse géolocalisée approximativement", result.display_name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If auto-geocoding fails, show a subtle warning
|
||||||
|
this.showGeocodingWarning(address)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Auto-geocoding failed:", error)
|
||||||
|
this.showGeocodingWarning(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual geocoding request with fallback strategies
|
||||||
|
async performGeocode(address) {
|
||||||
|
// Rate limiting: ensure at least 1 second between requests
|
||||||
|
const now = Date.now()
|
||||||
|
const timeSinceLastRequest = now - (this.constructor.lastGeocodingRequest || 0)
|
||||||
|
if (timeSinceLastRequest < 1000) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLastRequest))
|
||||||
|
}
|
||||||
|
this.constructor.lastGeocodingRequest = Date.now()
|
||||||
|
|
||||||
|
// Try multiple geocoding strategies
|
||||||
|
const strategies = [
|
||||||
|
// Strategy 1: Exact address
|
||||||
|
address,
|
||||||
|
// Strategy 2: Street name + city (remove house number)
|
||||||
|
address.replace(/^\d+\s*/, ''),
|
||||||
|
// Strategy 3: Just city and postal code
|
||||||
|
this.extractCityAndPostalCode(address)
|
||||||
|
].filter(Boolean) // Remove null/undefined values
|
||||||
|
|
||||||
|
for (let i = 0; i < strategies.length; i++) {
|
||||||
|
const searchAddress = strategies[i]
|
||||||
|
console.log(`Geocoding attempt ${i + 1}: "${searchAddress}"`)
|
||||||
|
|
||||||
|
// Show progress for manual geocoding (not auto-geocoding)
|
||||||
|
if (this.isManualGeocodingInProgress) {
|
||||||
|
const strategyNames = ['adresse complète', 'rue et ville', 'ville seulement']
|
||||||
|
this.showGeocodingProgress(strategyNames[i] || `stratégie ${i + 1}`, `${i + 1}/${strategies.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.tryGeocode(searchAddress)
|
||||||
|
if (result) {
|
||||||
|
console.log(`Geocoding successful with strategy ${i + 1}`)
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Strategy ${i + 1} failed:`, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add small delay between attempts
|
||||||
|
if (i < strategies.length - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
|
|
||||||
|
console.log('All geocoding strategies failed')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract city and postal code from address
|
||||||
|
extractCityAndPostalCode(address) {
|
||||||
|
// Look for French postal code pattern (5 digits) + city
|
||||||
|
const match = address.match(/(\d{5})\s+([^,]+)/);
|
||||||
|
if (match) {
|
||||||
|
return `${match[1]} ${match[2].trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract last part after comma (assume it's city)
|
||||||
|
const parts = address.split(',')
|
||||||
|
if (parts.length > 1) {
|
||||||
|
return parts[parts.length - 1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a single geocoding request
|
||||||
|
async tryGeocode(address) {
|
||||||
|
const encodedAddress = encodeURIComponent(address.trim())
|
||||||
|
|
||||||
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1&addressdetails=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const result = data[0]
|
||||||
|
return {
|
||||||
|
lat: parseFloat(result.lat).toFixed(6),
|
||||||
|
lng: parseFloat(result.lon).toFixed(6),
|
||||||
|
display_name: result.display_name,
|
||||||
|
accuracy: address === result.display_name ? 'exact' : 'approximate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update map links based on current coordinates
|
||||||
|
updateMapLinks() {
|
||||||
|
if (!this.hasMapLinksContainerTarget) return
|
||||||
|
|
||||||
|
const lat = parseFloat(this.latitudeTarget.value)
|
||||||
|
const lng = parseFloat(this.longitudeTarget.value)
|
||||||
|
const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
|
||||||
|
|
||||||
|
if (isNaN(lat) || isNaN(lng) || !address) {
|
||||||
|
this.clearMapLinks()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = this.generateMapLinks(lat, lng, address)
|
||||||
|
this.mapLinksContainerTarget.innerHTML = links
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate map links HTML
|
||||||
|
generateMapLinks(lat, lng, address) {
|
||||||
|
const encodedAddress = encodeURIComponent(address)
|
||||||
|
|
||||||
|
const providers = {
|
||||||
|
google: {
|
||||||
|
name: "Google Maps",
|
||||||
|
url: `https://www.google.com/maps/search/${encodedAddress},16z`,
|
||||||
|
icon: "🔍"
|
||||||
|
},
|
||||||
|
openstreetmap: {
|
||||||
|
name: "OpenStreetMap",
|
||||||
|
url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`,
|
||||||
|
icon: "🗺️"
|
||||||
|
},
|
||||||
|
apple: {
|
||||||
|
name: "Apple Plans",
|
||||||
|
url: `https://maps.apple.com/?address=${encodedAddress}&ll=${lat},${lng}`,
|
||||||
|
icon: "🍎"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Voir sur la carte :</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
${Object.entries(providers).map(([key, provider]) => `
|
||||||
|
<a href="${provider.url}" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<span class="mr-2">${provider.icon}</span>
|
||||||
|
${provider.name}
|
||||||
|
</a>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear coordinates
|
||||||
|
clearCoordinates() {
|
||||||
|
if (this.hasLatitudeTarget) this.latitudeTarget.value = ""
|
||||||
|
if (this.hasLongitudeTarget) this.longitudeTarget.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear map links
|
||||||
|
clearMapLinks() {
|
||||||
|
if (this.hasMapLinksContainerTarget) {
|
||||||
|
this.mapLinksContainerTarget.innerHTML = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding spinner in address input
|
||||||
|
showGeocodingSpinner() {
|
||||||
|
if (this.hasGeocodingSpinnerTarget) {
|
||||||
|
this.geocodingSpinnerTarget.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide geocoding spinner in address input
|
||||||
|
hideGeocodingSpinner() {
|
||||||
|
if (this.hasGeocodingSpinnerTarget) {
|
||||||
|
this.geocodingSpinnerTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state on "Ma position" button
|
||||||
|
showGetCurrentLocationLoading() {
|
||||||
|
if (this.hasGetCurrentLocationBtnTarget) {
|
||||||
|
this.getCurrentLocationBtnTarget.disabled = true
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationIconTarget) {
|
||||||
|
this.getCurrentLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-white border-t-transparent rounded-full animate-spin"></div>'
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationTextTarget) {
|
||||||
|
this.getCurrentLocationTextTarget.textContent = 'Localisation...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state on "Ma position" button
|
||||||
|
hideGetCurrentLocationLoading() {
|
||||||
|
if (this.hasGetCurrentLocationBtnTarget) {
|
||||||
|
this.getCurrentLocationBtnTarget.disabled = false
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationIconTarget) {
|
||||||
|
this.getCurrentLocationIconTarget.innerHTML = '<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>'
|
||||||
|
// Re-initialize Lucide icons
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.hasGetCurrentLocationTextTarget) {
|
||||||
|
this.getCurrentLocationTextTarget.textContent = 'Ma position'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state on "Prévisualiser" button
|
||||||
|
showPreviewLocationLoading() {
|
||||||
|
if (this.hasPreviewLocationBtnTarget) {
|
||||||
|
this.previewLocationBtnTarget.disabled = true
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationIconTarget) {
|
||||||
|
this.previewLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-purple-700 border-t-transparent rounded-full animate-spin"></div>'
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationTextTarget) {
|
||||||
|
this.previewLocationTextTarget.textContent = 'Recherche...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state on "Prévisualiser" button
|
||||||
|
hidePreviewLocationLoading() {
|
||||||
|
if (this.hasPreviewLocationBtnTarget) {
|
||||||
|
this.previewLocationBtnTarget.disabled = false
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationIconTarget) {
|
||||||
|
this.previewLocationIconTarget.innerHTML = '<i data-lucide="map" class="w-3 h-3 mr-1"></i>'
|
||||||
|
// Re-initialize Lucide icons
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.hasPreviewLocationTextTarget) {
|
||||||
|
this.previewLocationTextTarget.textContent = 'Prévisualiser'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
showLocationLoading() {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-loading", "Géolocalisation en cours...", "loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state
|
||||||
|
hideLocationLoading() {
|
||||||
|
this.hideMessage("location-loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showLocationSuccess(message) {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-success", message, "success")
|
||||||
|
setTimeout(() => this.hideMessage("location-success"), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
showLocationError(message) {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-error", message, "error")
|
||||||
|
setTimeout(() => this.hideMessage("location-error"), 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding warning (less intrusive than error)
|
||||||
|
showGeocodingWarning(address) {
|
||||||
|
this.hideMessage("geocoding-warning")
|
||||||
|
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
|
||||||
|
this.showMessage("geocoding-warning", message, "warning")
|
||||||
|
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show info about approximate location
|
||||||
|
showApproximateLocationInfo(foundLocation) {
|
||||||
|
this.hideMessage("approximate-location-info")
|
||||||
|
const message = `Localisation approximative trouvée: ${foundLocation}`
|
||||||
|
this.showMessage("approximate-location-info", message, "info")
|
||||||
|
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding success with location details
|
||||||
|
showGeocodingSuccess(title, location) {
|
||||||
|
this.hideMessage("geocoding-success")
|
||||||
|
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
||||||
|
this.showMessage("geocoding-success", message, "success")
|
||||||
|
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding progress with strategy info
|
||||||
|
showGeocodingProgress(strategy, attempt) {
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
|
const message = `Recherche en cours... (${attempt}/${strategy})`
|
||||||
|
this.showMessage("geocoding-progress", message, "loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message template configurations
|
||||||
|
getMessageTemplate(type) {
|
||||||
|
const templates = {
|
||||||
|
info: {
|
||||||
|
bgColor: "bg-blue-50",
|
||||||
|
borderColor: "border-blue-200",
|
||||||
|
textColor: "text-blue-800",
|
||||||
|
icon: "info",
|
||||||
|
iconColor: "text-blue-500"
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
bgColor: "bg-green-50",
|
||||||
|
borderColor: "border-green-200",
|
||||||
|
textColor: "text-green-800",
|
||||||
|
icon: "check-circle",
|
||||||
|
iconColor: "text-green-500"
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
borderColor: "border-red-200",
|
||||||
|
textColor: "text-red-800",
|
||||||
|
icon: "alert-circle",
|
||||||
|
iconColor: "text-red-500"
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bgColor: "bg-yellow-50",
|
||||||
|
borderColor: "border-yellow-200",
|
||||||
|
textColor: "text-yellow-800",
|
||||||
|
icon: "alert-triangle",
|
||||||
|
iconColor: "text-yellow-500"
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
bgColor: "bg-purple-50",
|
||||||
|
borderColor: "border-purple-200",
|
||||||
|
textColor: "text-purple-800",
|
||||||
|
icon: "loader-2",
|
||||||
|
iconColor: "text-purple-500",
|
||||||
|
animated: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return templates[type] || templates.info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dynamic message HTML using template
|
||||||
|
createMessageHTML(id, message, type) {
|
||||||
|
const template = this.getMessageTemplate(type)
|
||||||
|
const animationClass = template.animated ? 'animate-spin' : ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div id="${id}" class="flex items-start space-x-3 p-4 ${template.bgColor} ${template.borderColor} border rounded-lg shadow-sm transition-all duration-200 ease-in-out">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i data-lucide="${template.icon}" class="w-5 h-5 ${template.iconColor} ${animationClass}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium ${template.textColor} leading-relaxed">${message}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="flex-shrink-0 ${template.textColor} hover:opacity-70 transition-opacity">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a message with given type using template system
|
||||||
|
showMessage(id, message, type) {
|
||||||
|
// Remove existing message with same ID first
|
||||||
|
this.hideMessage(id)
|
||||||
|
|
||||||
|
const messageHtml = this.createMessageHTML(id, message, type)
|
||||||
|
|
||||||
|
// Insert into the dedicated messages container in the venue section
|
||||||
|
if (this.hasMessagesContainerTarget) {
|
||||||
|
this.messagesContainerTarget.insertAdjacentHTML('beforeend', messageHtml)
|
||||||
|
// Re-initialize Lucide icons for the new elements
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: insert before the address input if messages container not found
|
||||||
|
const addressInput = this.hasAddressTarget ? this.addressTarget.parentElement : null
|
||||||
|
if (addressInput) {
|
||||||
|
addressInput.insertAdjacentHTML('beforebegin', messageHtml)
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide a specific message
|
||||||
|
hideMessage(id) {
|
||||||
|
const element = document.getElementById(id)
|
||||||
|
if (element) {
|
||||||
|
element.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide all location messages
|
||||||
|
hideAllLocationMessages() {
|
||||||
|
this.hideMessage("location-loading")
|
||||||
|
this.hideMessage("location-success")
|
||||||
|
this.hideMessage("location-error")
|
||||||
|
this.hideMessage("geocoding-warning")
|
||||||
|
this.hideMessage("approximate-location-info")
|
||||||
|
this.hideMessage("geocoding-success")
|
||||||
|
this.hideMessage("geocoding-progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["card"]
|
|
||||||
static classes = ["visible"]
|
|
||||||
static values = {
|
|
||||||
threshold: { type: Number, default: 0.1 },
|
|
||||||
rootMargin: { type: String, default: '0px 0px -50px 0px' },
|
|
||||||
staggerDelay: { type: Number, default: 0.2 }
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
console.log("FeaturedEventController connected")
|
|
||||||
this.setupIntersectionObserver()
|
|
||||||
this.setupStaggeredAnimations()
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.observer) {
|
|
||||||
this.observer.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupIntersectionObserver() {
|
|
||||||
const observerOptions = {
|
|
||||||
threshold: this.thresholdValue,
|
|
||||||
rootMargin: this.rootMarginValue
|
|
||||||
}
|
|
||||||
|
|
||||||
this.observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.classList.add('visible')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, observerOptions)
|
|
||||||
|
|
||||||
// Observe all card elements within this controller's scope
|
|
||||||
const elements = this.cardTargets
|
|
||||||
console.log("Card targets:", elements)
|
|
||||||
elements.forEach(el => {
|
|
||||||
this.observer.observe(el)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setupStaggeredAnimations() {
|
|
||||||
console.log("Setting up staggered animations")
|
|
||||||
console.log("Card targets:", this.cardTargets)
|
|
||||||
// Add staggered animation delays to cards
|
|
||||||
this.cardTargets.forEach((card, index) => {
|
|
||||||
card.style.transitionDelay = `${index * this.staggerDelayValue}s`
|
|
||||||
card.classList.remove('visible')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** Old code
|
|
||||||
<script>
|
|
||||||
// Add animation classes when elements are in view
|
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
|
||||||
const observerOptions = {
|
|
||||||
threshold: 0.1,
|
|
||||||
rootMargin: '0px 0px -50px 0px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.classList.add('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, observerOptions);
|
|
||||||
|
|
||||||
// Observe animated elements
|
|
||||||
document.querySelectorAll('.animate-fadeInUp, .animate-slideInLeft, .animate-slideInRight').forEach(el => {
|
|
||||||
observer.observe(el);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add staggered animation delays
|
|
||||||
document.querySelectorAll('.featured-event-card').forEach((card, index) => {
|
|
||||||
card.style.transitionDelay = `${index * 0.2}s`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
*/
|
|
||||||
@@ -1,32 +1,46 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Controller for handling flash messages
|
||||||
|
// Automatically dismisses messages after a timeout and handles manual closing
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["message"]
|
// Define targets for the controller
|
||||||
|
static targets = ["message"];
|
||||||
|
|
||||||
|
// Initialize the controller when it connects to the DOM
|
||||||
connect() {
|
connect() {
|
||||||
console.log("FlashMessageController mounted", this.element);
|
// console.log("FlashMessageController mounted", this.element);
|
||||||
|
console.log("FlashMessageController mounted");
|
||||||
|
|
||||||
// Initialize Lucide icons for this element
|
// Initialize Lucide icons for this element if available
|
||||||
if (typeof lucide !== 'undefined') {
|
if (typeof lucide !== "undefined") {
|
||||||
lucide.createIcons({ within: this.element });
|
lucide.createIcons({ within: this.element });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-dismiss after 2 seconds
|
// Auto-dismiss after 2 seconds
|
||||||
this.timeout = setTimeout(() => {
|
this.timeout = setTimeout(() => {
|
||||||
this.close()
|
this.close();
|
||||||
}, 2000)
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up the timeout when the controller disconnects
|
||||||
disconnect() {
|
disconnect() {
|
||||||
if (this.timeout) {
|
if (this.timeout) {
|
||||||
clearTimeout(this.timeout)
|
clearTimeout(this.timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close the flash message with a fade-out animation
|
||||||
close() {
|
close() {
|
||||||
this.element.classList.add('opacity-0', 'transition-opacity', 'duration-300')
|
// Add opacity transition classes
|
||||||
|
this.element.classList.add(
|
||||||
|
"opacity-0",
|
||||||
|
"transition-opacity",
|
||||||
|
"duration-300",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove element after transition completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.element.remove()
|
this.element.remove();
|
||||||
}, 300)
|
}, 300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
app/javascript/controllers/header_controller.js
Normal file
73
app/javascript/controllers/header_controller.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Controller for handling the header navigation
|
||||||
|
// Manages mobile menu toggle and user dropdown menu
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["mobileMenu", "mobileMenuButton", "userMenu", "userMenuButton"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Initialize menu states
|
||||||
|
this.mobileMenuOpen = false
|
||||||
|
this.userMenuOpen = false
|
||||||
|
|
||||||
|
// Add click outside listener for user menu
|
||||||
|
this.clickOutsideHandler = this.handleClickOutside.bind(this)
|
||||||
|
document.addEventListener("click", this.clickOutsideHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
// Clean up event listener
|
||||||
|
document.removeEventListener("click", this.clickOutsideHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle mobile menu visibility
|
||||||
|
toggleMobileMenu() {
|
||||||
|
this.mobileMenuOpen = !this.mobileMenuOpen
|
||||||
|
this.mobileMenuTarget.classList.toggle("hidden", !this.mobileMenuOpen)
|
||||||
|
|
||||||
|
// Update button icon based on state
|
||||||
|
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
|
||||||
|
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
|
||||||
|
|
||||||
|
if (iconOpen && iconClose) {
|
||||||
|
iconOpen.classList.toggle("hidden", this.mobileMenuOpen)
|
||||||
|
iconClose.classList.toggle("hidden", !this.mobileMenuOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle user dropdown menu visibility
|
||||||
|
toggleUserMenu() {
|
||||||
|
this.userMenuOpen = !this.userMenuOpen
|
||||||
|
if (this.hasUserMenuTarget) {
|
||||||
|
this.userMenuTarget.classList.toggle("hidden", !this.userMenuOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menus when clicking outside
|
||||||
|
handleClickOutside(event) {
|
||||||
|
// Close user menu if clicked outside
|
||||||
|
if (this.userMenuOpen && this.hasUserMenuTarget &&
|
||||||
|
!this.userMenuTarget.contains(event.target) &&
|
||||||
|
!this.userMenuButtonTarget.contains(event.target)) {
|
||||||
|
this.userMenuOpen = false
|
||||||
|
this.userMenuTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close mobile menu if clicked outside
|
||||||
|
if (this.mobileMenuOpen &&
|
||||||
|
!this.mobileMenuTarget.contains(event.target) &&
|
||||||
|
!this.mobileMenuButtonTarget.contains(event.target)) {
|
||||||
|
this.mobileMenuOpen = false
|
||||||
|
this.mobileMenuTarget.classList.add("hidden")
|
||||||
|
|
||||||
|
// Update button icon
|
||||||
|
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
|
||||||
|
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
|
||||||
|
|
||||||
|
if (iconOpen && iconClose) {
|
||||||
|
iconOpen.classList.remove("hidden")
|
||||||
|
iconClose.classList.add("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,22 +2,31 @@
|
|||||||
// Run that command whenever you add a new controller or create them with
|
// Run that command whenever you add a new controller or create them with
|
||||||
// ./bin/rails generate stimulus controllerName
|
// ./bin/rails generate stimulus controllerName
|
||||||
|
|
||||||
import { application } from "./application"
|
import { application } from "./application";
|
||||||
|
|
||||||
import LogoutController from "./logout_controller"
|
import LogoutController from "./logout_controller";
|
||||||
import FlashMessageController from "./flash_message_controller"
|
application.register("logout", LogoutController);
|
||||||
import CounterController from "./counter_controller"
|
|
||||||
// import FeaturedEventController from "./featured_event_controller"
|
|
||||||
|
|
||||||
|
import CounterController from "./counter_controller";
|
||||||
|
application.register("counter", CounterController);
|
||||||
|
|
||||||
// application.register("logout", LogoutController) // Allow logout using js
|
import FlashMessageController from "./flash_message_controller";
|
||||||
// application.register("flash-message", FlashMessageController) // Dismiss notification after 5 secondes
|
application.register("flash-message", FlashMessageController);
|
||||||
// application.register("counter", CounterController) // Simple counter for homepage
|
|
||||||
// application.register("featured-event", FeaturedEventController) // Featured event controller for homepage
|
|
||||||
|
|
||||||
|
import TicketSelectionController from "./ticket_selection_controller";
|
||||||
|
application.register("ticket-selection", TicketSelectionController);
|
||||||
|
|
||||||
|
import HeaderController from "./header_controller";
|
||||||
|
application.register("header", HeaderController);
|
||||||
|
|
||||||
// import ShadcnTestController from "./shadcn_test_controller"
|
import QrCodeController from "./qr_code_controller";
|
||||||
// application.register("shadcn-test", ShadcnTestController) // Test controller for Shadcn
|
application.register("qr-code", QrCodeController);
|
||||||
|
|
||||||
|
import EventFormController from "./event_form_controller";
|
||||||
|
application.register("event-form", EventFormController);
|
||||||
|
|
||||||
|
import CountdownController from "./countdown_controller";
|
||||||
|
application.register("countdown", CountdownController);
|
||||||
|
|
||||||
|
import EventDuplicationController from "./event_duplication_controller";
|
||||||
|
application.register("event-duplication", EventDuplicationController);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { Controller } from "@hotwired/stimulus";
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Controller for handling user logout functionality
|
||||||
|
// Sends a DELETE request to the server to sign out the user
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
// Define controller values
|
||||||
static values = {
|
static values = {
|
||||||
url: String,
|
url: String, // Optional URL for logout endpoint
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Log when the controller is mounted
|
||||||
connect() {
|
connect() {
|
||||||
// Display a message when the controller is mounted
|
// Display a message when the controller is mounted
|
||||||
console.log("LogoutController mounted", this.element);
|
// console.log("LogoutController mounted", this.element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle the sign out action
|
||||||
signOut(event) {
|
signOut(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("User clicked on logout button.");
|
console.log("User clicked on logout button.");
|
||||||
@@ -17,7 +22,7 @@ export default class extends Controller {
|
|||||||
// Ensure user wants to disconnect with a confirmation request
|
// Ensure user wants to disconnect with a confirmation request
|
||||||
// if (this.hasUrlValue && !confirm(this.element.dataset.confirm)) { return; }
|
// if (this.hasUrlValue && !confirm(this.element.dataset.confirm)) { return; }
|
||||||
|
|
||||||
// Retrieve the csrf token from header
|
// Retrieve the csrf token from header for security
|
||||||
const csrfToken = document.querySelector("[name='csrf-token']").content;
|
const csrfToken = document.querySelector("[name='csrf-token']").content;
|
||||||
|
|
||||||
// Define url to redirect user when action is valid
|
// Define url to redirect user when action is valid
|
||||||
|
|||||||
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,39 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import React from "react"
|
|
||||||
import { createRoot } from "react-dom/client"
|
|
||||||
import { Button } from "@/components/button"
|
|
||||||
|
|
||||||
// Connects to data-controller="shadcn-test"
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["container"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
console.log("Shadcn Button Test Controller connected")
|
|
||||||
this.renderButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
renderButton() {
|
|
||||||
const container = this.containerTarget
|
|
||||||
const root = createRoot(container)
|
|
||||||
|
|
||||||
root.render(
|
|
||||||
<div className="flex flex-col items-center gap-4 p-6">
|
|
||||||
<h3 className="text-white text-lg font-semibold">Test Button Shadcn</h3>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
|
|
||||||
onClick={this.handleClick}
|
|
||||||
>
|
|
||||||
Cliquez ici - PostCSS Test
|
|
||||||
</Button>
|
|
||||||
<p className="text-gray-300 text-sm">Ce bouton utilise shadcn/ui + Tailwind + PostCSS</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick = () => {
|
|
||||||
alert("✅ Le bouton shadcn fonctionne avec PostCSS !")
|
|
||||||
console.log("Shadcn button clicked - PostCSS compilation successful")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
169
app/javascript/controllers/ticket_selection_controller.js
Normal file
169
app/javascript/controllers/ticket_selection_controller.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Controller for handling ticket selection on the event show page
|
||||||
|
// Manages quantity inputs, calculates totals, and enables/disables the checkout button
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [
|
||||||
|
"quantityInput",
|
||||||
|
"totalQuantity",
|
||||||
|
"totalAmount",
|
||||||
|
"checkoutButton",
|
||||||
|
"form",
|
||||||
|
];
|
||||||
|
static values = { eventSlug: String, eventId: String, orderNewUrl: String, storeCartUrl: String };
|
||||||
|
|
||||||
|
// Initialize the controller and update the cart summary
|
||||||
|
connect() {
|
||||||
|
this.updateCartSummary();
|
||||||
|
this.bindFormSubmission();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind form submission to handle cart storage
|
||||||
|
bindFormSubmission() {
|
||||||
|
if (this.hasFormTarget) {
|
||||||
|
this.formTarget.addEventListener("submit", this.submitCart.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment the quantity for a specific ticket type
|
||||||
|
increment(event) {
|
||||||
|
const ticketTypeId = event.currentTarget.dataset.target;
|
||||||
|
const input = this.quantityInputTargets.find(
|
||||||
|
(input) => input.dataset.target === ticketTypeId,
|
||||||
|
);
|
||||||
|
const value = parseInt(input.value) || 0;
|
||||||
|
const max = parseInt(input.max) || 0;
|
||||||
|
|
||||||
|
if (value < max) {
|
||||||
|
input.value = value + 1;
|
||||||
|
this.updateCartSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement the quantity for a specific ticket type
|
||||||
|
decrement(event) {
|
||||||
|
const ticketTypeId = event.currentTarget.dataset.target;
|
||||||
|
const input = this.quantityInputTargets.find(
|
||||||
|
(input) => input.dataset.target === ticketTypeId,
|
||||||
|
);
|
||||||
|
const value = parseInt(input.value) || 0;
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
input.value = value - 1;
|
||||||
|
this.updateCartSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update quantity when directly edited in the input field
|
||||||
|
updateQuantity(event) {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
let value = parseInt(input.value) || 0;
|
||||||
|
const max = parseInt(input.max) || 0;
|
||||||
|
|
||||||
|
// Ensure value is within valid range (0 to max available)
|
||||||
|
if (value < 0) value = 0;
|
||||||
|
if (value > max) value = max;
|
||||||
|
|
||||||
|
input.value = value;
|
||||||
|
this.updateCartSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate and update the cart summary (total quantity and amount)
|
||||||
|
updateCartSummary() {
|
||||||
|
let totalQuantity = 0;
|
||||||
|
let totalAmount = 0;
|
||||||
|
|
||||||
|
// Sum up quantities and calculate total amount
|
||||||
|
this.quantityInputTargets.forEach((input) => {
|
||||||
|
const quantity = parseInt(input.value) || 0;
|
||||||
|
const price = parseInt(input.dataset.price) || 0;
|
||||||
|
|
||||||
|
totalQuantity += quantity;
|
||||||
|
totalAmount += quantity * price;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the displayed total quantity and amount
|
||||||
|
this.totalQuantityTarget.textContent = totalQuantity;
|
||||||
|
this.totalAmountTarget.textContent = `€${(totalAmount / 100).toFixed(2)}`;
|
||||||
|
|
||||||
|
// Enable/disable checkout button based on whether any tickets are selected
|
||||||
|
if (totalQuantity > 0) {
|
||||||
|
this.checkoutButtonTarget.classList.remove(
|
||||||
|
"opacity-50",
|
||||||
|
"cursor-not-allowed",
|
||||||
|
);
|
||||||
|
this.checkoutButtonTarget.disabled = false;
|
||||||
|
} else {
|
||||||
|
this.checkoutButtonTarget.classList.add(
|
||||||
|
"opacity-50",
|
||||||
|
"cursor-not-allowed",
|
||||||
|
);
|
||||||
|
this.checkoutButtonTarget.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission - store cart in session before proceeding
|
||||||
|
async submitCart(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const cartData = this.buildCartData();
|
||||||
|
|
||||||
|
if (Object.keys(cartData).length === 0) {
|
||||||
|
alert("Veuillez sélectionner au moins un billet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Store cart data in session
|
||||||
|
await this.storeCartInSession(cartData);
|
||||||
|
|
||||||
|
// Redirect to event-scoped orders/new page
|
||||||
|
const orderNewUrl = this.orderNewUrlValue;
|
||||||
|
window.location.href = orderNewUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error storing cart:", error);
|
||||||
|
alert("Une erreur est survenue. Veuillez réessayer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cart data from current form state
|
||||||
|
buildCartData() {
|
||||||
|
const cartData = {};
|
||||||
|
|
||||||
|
this.quantityInputTargets.forEach((input) => {
|
||||||
|
const quantity = parseInt(input.value) || 0;
|
||||||
|
if (quantity > 0) {
|
||||||
|
const ticketTypeId = input.dataset.target;
|
||||||
|
cartData[ticketTypeId] = {
|
||||||
|
quantity: quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store cart data in session via AJAX
|
||||||
|
async storeCartInSession(cartData) {
|
||||||
|
const storeCartUrl = this.storeCartUrlValue;
|
||||||
|
|
||||||
|
const response = await fetch(storeCartUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": document
|
||||||
|
.querySelector('meta[name="csrf-token"]')
|
||||||
|
.getAttribute("content"),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ cart: cartData, event_id: this.eventIdValue }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to store cart data: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
33
app/jobs/cleanup_expired_drafts_job.rb
Normal file
33
app/jobs/cleanup_expired_drafts_job.rb
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Background job to clean up expired draft tickets
|
||||||
|
#
|
||||||
|
# This job runs periodically to find and expire draft tickets that have
|
||||||
|
# passed their expiry time (typically 30 minutes after creation).
|
||||||
|
# Should be scheduled via cron or similar scheduling system.
|
||||||
|
class CleanupExpiredDraftsJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# Find and expire all draft tickets that have passed their expiry time
|
||||||
|
#
|
||||||
|
# Uses find_each to process tickets in batches to avoid memory issues
|
||||||
|
# with large datasets. Continues processing even if individual tickets fail.
|
||||||
|
def perform
|
||||||
|
expired_count = 0
|
||||||
|
|
||||||
|
# Process expired draft tickets in batches
|
||||||
|
Ticket.expired_drafts.find_each do |ticket|
|
||||||
|
begin
|
||||||
|
Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user.id}"
|
||||||
|
ticket.expire_if_overdue!
|
||||||
|
expired_count += 1
|
||||||
|
rescue => e
|
||||||
|
# Log error but continue processing other tickets
|
||||||
|
Rails.logger.error "Failed to expire ticket #{ticket.id}: #{e.message}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Log summary if any tickets were processed
|
||||||
|
Rails.logger.info "Expired #{expired_count} draft tickets" if expired_count > 0
|
||||||
|
Rails.logger.info "No expired draft tickets found" if expired_count == 0
|
||||||
|
end
|
||||||
|
end
|
||||||
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
|
||||||
23
app/jobs/expired_orders_cleanup_job.rb
Normal file
23
app/jobs/expired_orders_cleanup_job.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class ExpiredOrdersCleanupJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
# Find and expire all draft orders that have passed their expiry time
|
||||||
|
expired_orders = Order.expired_drafts
|
||||||
|
|
||||||
|
Rails.logger.info "Found #{expired_orders.count} expired orders to process"
|
||||||
|
|
||||||
|
expired_orders.find_each do |order|
|
||||||
|
begin
|
||||||
|
order.expire_if_overdue!
|
||||||
|
Rails.logger.info "Expired order ##{order.id} for user ##{order.user_id}"
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to expire order ##{order.id}: #{e.message}"
|
||||||
|
# Continue processing other orders even if one fails
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Completed expired orders cleanup job"
|
||||||
|
end
|
||||||
|
end
|
||||||
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,21 +1,80 @@
|
|||||||
class TicketMailer < ApplicationMailer
|
class TicketMailer < ApplicationMailer
|
||||||
default from: 'notifications@aperonight.com'
|
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
|
||||||
@user = ticket.user
|
@user = ticket.user
|
||||||
@event = ticket.event
|
@event = ticket.event
|
||||||
|
|
||||||
# Generate PDF attachment
|
# Generate PDF attachment
|
||||||
pdf = @ticket.to_pdf
|
begin
|
||||||
attachments["ticket-#{@event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf"] = {
|
pdf = @ticket.to_pdf
|
||||||
mime_type: 'application/pdf',
|
attachments["ticket-#{@event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf"] = {
|
||||||
content: pdf
|
mime_type: "application/pdf",
|
||||||
}
|
content: pdf
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Failed to generate PDF for ticket #{@ticket.id}: #{e.message}"
|
||||||
|
# Continue without PDF attachment rather than failing the entire email
|
||||||
|
end
|
||||||
|
|
||||||
mail(
|
mail(
|
||||||
to: @user.email,
|
to: @user.email,
|
||||||
subject: "Confirmation d'achat - #{@event.name}"
|
subject: "Confirmation d'achat - #{@event.name}"
|
||||||
)
|
)
|
||||||
end
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,14 +18,18 @@ class Event < ApplicationRecord
|
|||||||
|
|
||||||
# === Relations ===
|
# === Relations ===
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :ticket_types, dependent: :destroy
|
has_many :ticket_types
|
||||||
has_many :tickets, through: :ticket_types
|
has_many :tickets, through: :ticket_types
|
||||||
|
has_many :orders
|
||||||
|
|
||||||
|
# === Callbacks ===
|
||||||
|
before_validation :geocode_address, if: :should_geocode_address?
|
||||||
|
|
||||||
# Validations for Event attributes
|
# Validations for Event attributes
|
||||||
# Basic information
|
# Basic information
|
||||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||||
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
||||||
validates :description, presence: true, length: { minimum: 10, maximum: 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
|
||||||
|
|
||||||
@@ -48,4 +55,218 @@ class Event < ApplicationRecord
|
|||||||
# Scope for published events ordered by start time
|
# Scope for published events ordered by start time
|
||||||
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
||||||
|
|
||||||
|
|
||||||
|
# === Instance Methods ===
|
||||||
|
|
||||||
|
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||||
|
def geocoding_successful?
|
||||||
|
coordinates_look_valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a user-friendly status message about geocoding
|
||||||
|
def geocoding_status_message
|
||||||
|
return nil if geocoding_successful?
|
||||||
|
|
||||||
|
"Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if ticket booking is currently allowed for this event
|
||||||
|
def booking_allowed?
|
||||||
|
return false unless published?
|
||||||
|
return false if sold_out?
|
||||||
|
return false if canceled?
|
||||||
|
|
||||||
|
# Check if event has started and if booking during event is disabled
|
||||||
|
if event_started? && !allow_booking_during_event?
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the event has already started
|
||||||
|
def event_started?
|
||||||
|
return false if start_time.blank?
|
||||||
|
Time.current >= start_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the event has ended
|
||||||
|
def event_ended?
|
||||||
|
return false if end_time.blank?
|
||||||
|
Time.current >= end_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if booking is allowed during the event
|
||||||
|
# This is a simple attribute reader that defaults to false if nil
|
||||||
|
def allow_booking_during_event?
|
||||||
|
!!allow_booking_during_event
|
||||||
|
end
|
||||||
|
|
||||||
|
# Duplicate an event with all its ticket types
|
||||||
|
def duplicate(clone_ticket_types: true)
|
||||||
|
# Duplicate the event
|
||||||
|
new_event = self.dup
|
||||||
|
new_event.name = "Copie de #{name}"
|
||||||
|
new_event.slug = "#{slug}-copy-#{Time.current.to_i}"
|
||||||
|
new_event.state = :draft
|
||||||
|
new_event.created_at = Time.current
|
||||||
|
new_event.updated_at = Time.current
|
||||||
|
|
||||||
|
Event.transaction do
|
||||||
|
if new_event.save
|
||||||
|
# Duplicate all ticket types if requested
|
||||||
|
if clone_ticket_types
|
||||||
|
ticket_types.each do |ticket_type|
|
||||||
|
new_ticket_type = ticket_type.dup
|
||||||
|
new_ticket_type.event = new_event
|
||||||
|
new_ticket_type.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
new_event
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Determine if we should perform server-side geocoding
|
||||||
|
def should_geocode_address?
|
||||||
|
# Don't geocode if address is blank
|
||||||
|
return false if venue_address.blank?
|
||||||
|
|
||||||
|
# Don't geocode if we already have valid coordinates (likely from frontend)
|
||||||
|
return false if coordinates_look_valid?
|
||||||
|
|
||||||
|
# Only geocode if address changed and we don't have coordinates
|
||||||
|
venue_address_changed?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the current coordinates look like they were set by frontend geocoding
|
||||||
|
def coordinates_look_valid?
|
||||||
|
return false if latitude.blank? || longitude.blank?
|
||||||
|
|
||||||
|
lat_f = latitude.to_f
|
||||||
|
lng_f = longitude.to_f
|
||||||
|
|
||||||
|
# Basic sanity checks for coordinate ranges
|
||||||
|
return false if lat_f < -90 || lat_f > 90
|
||||||
|
return false if lng_f < -180 || lng_f > 180
|
||||||
|
|
||||||
|
# Check if coordinates are not the default fallback coordinates
|
||||||
|
fallback_lat = 46.603354
|
||||||
|
fallback_lng = 1.888334
|
||||||
|
|
||||||
|
# Check if coordinates are not exactly 0,0 (common invalid default)
|
||||||
|
return false if lat_f == 0.0 && lng_f == 0.0
|
||||||
|
|
||||||
|
# Coordinates are valid if they're not exactly the fallback coordinates
|
||||||
|
!(lat_f == fallback_lat && lng_f == fallback_lng)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Automatically geocode address to get latitude and longitude
|
||||||
|
# This only runs when no valid coordinates are provided (fallback for non-JS users)
|
||||||
|
def geocode_address
|
||||||
|
Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)"
|
||||||
|
|
||||||
|
# Store original coordinates in case we need to fall back
|
||||||
|
original_lat = latitude
|
||||||
|
original_lng = longitude
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Use OpenStreetMap Nominatim API for geocoding
|
||||||
|
encoded_address = URI.encode_www_form_component(venue_address.strip)
|
||||||
|
uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1")
|
||||||
|
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.use_ssl = true
|
||||||
|
|
||||||
|
request = Net::HTTP::Get.new(uri)
|
||||||
|
request["User-Agent"] = "AperoNight Event Platform/1.0 (https://aperonight.com)"
|
||||||
|
request["Accept"] = "application/json"
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
|
||||||
|
if response.code == "200"
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
|
||||||
|
if data.any?
|
||||||
|
result = data.first
|
||||||
|
self.latitude = result["lat"].to_f.round(6)
|
||||||
|
self.longitude = result["lon"].to_f.round(6)
|
||||||
|
Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# If we reach here, geocoding failed
|
||||||
|
handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}"
|
||||||
|
handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle geocoding failure with fallback strategies
|
||||||
|
def handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
# Strategy 1: Keep existing coordinates if this is an update
|
||||||
|
if original_lat.present? && original_lng.present?
|
||||||
|
self.latitude = original_lat
|
||||||
|
self.longitude = original_lng
|
||||||
|
Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strategy 2: Try to extract country/city and use approximate coordinates
|
||||||
|
fallback_coordinates = get_fallback_coordinates_from_address
|
||||||
|
if fallback_coordinates
|
||||||
|
self.latitude = fallback_coordinates[:lat]
|
||||||
|
self.longitude = fallback_coordinates[:lng]
|
||||||
|
Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strategy 3: Use default coordinates (center of France) as last resort
|
||||||
|
# This ensures the event can still be created
|
||||||
|
self.latitude = 46.603354 # Center of France
|
||||||
|
self.longitude = 1.888334
|
||||||
|
Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract country/city from address and return approximate coordinates
|
||||||
|
def get_fallback_coordinates_from_address
|
||||||
|
address_lower = venue_address.downcase
|
||||||
|
|
||||||
|
# Common French cities with approximate coordinates
|
||||||
|
french_cities = {
|
||||||
|
"paris" => { lat: 48.8566, lng: 2.3522 },
|
||||||
|
"lyon" => { lat: 45.7640, lng: 4.8357 },
|
||||||
|
"marseille" => { lat: 43.2965, lng: 5.3698 },
|
||||||
|
"toulouse" => { lat: 43.6047, lng: 1.4442 },
|
||||||
|
"nice" => { lat: 43.7102, lng: 7.2620 },
|
||||||
|
"nantes" => { lat: 47.2184, lng: -1.5536 },
|
||||||
|
"montpellier" => { lat: 43.6110, lng: 3.8767 },
|
||||||
|
"strasbourg" => { lat: 48.5734, lng: 7.7521 },
|
||||||
|
"bordeaux" => { lat: 44.8378, lng: -0.5792 },
|
||||||
|
"lille" => { lat: 50.6292, lng: 3.0573 }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if any known city is mentioned in the address
|
||||||
|
french_cities.each do |city, coords|
|
||||||
|
if address_lower.include?(city)
|
||||||
|
return coords
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check for common country indicators
|
||||||
|
if address_lower.include?("france") || address_lower.include?("french")
|
||||||
|
return { lat: 46.603354, lng: 1.888334 } # Center of France
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
160
app/models/order.rb
Normal file
160
app/models/order.rb
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
class Order < ApplicationRecord
|
||||||
|
# === Constants ===
|
||||||
|
DRAFT_EXPIRY_TIME = 15.minutes
|
||||||
|
MAX_PAYMENT_ATTEMPTS = 3
|
||||||
|
|
||||||
|
# === Associations ===
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :event
|
||||||
|
has_many :tickets, dependent: :destroy
|
||||||
|
|
||||||
|
# === Validations ===
|
||||||
|
validates :user_id, presence: true
|
||||||
|
validates :event_id, presence: true
|
||||||
|
validates :status, presence: true, inclusion: {
|
||||||
|
in: %w[draft pending_payment paid completed cancelled expired]
|
||||||
|
}
|
||||||
|
validates :total_amount_cents, presence: true,
|
||||||
|
numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :payment_attempts, presence: true,
|
||||||
|
numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
|
||||||
|
# Stripe invoice ID for accounting records
|
||||||
|
attr_accessor :stripe_invoice_id
|
||||||
|
|
||||||
|
# === Scopes ===
|
||||||
|
scope :draft, -> { where(status: "draft") }
|
||||||
|
scope :active, -> { where(status: %w[paid completed]) }
|
||||||
|
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
|
||||||
|
scope :can_retry_payment, -> {
|
||||||
|
draft.where("payment_attempts < ? AND expires_at > ?",
|
||||||
|
MAX_PAYMENT_ATTEMPTS, Time.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
|
# === Instance Methods ===
|
||||||
|
|
||||||
|
# Total amount in euros (formatted)
|
||||||
|
def total_amount_euros
|
||||||
|
total_amount_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if order can be retried for payment
|
||||||
|
def can_retry_payment?
|
||||||
|
draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if order is expired
|
||||||
|
def expired?
|
||||||
|
expires_at.present? && expires_at < Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark order as expired if it's past expiry time
|
||||||
|
def expire_if_overdue!
|
||||||
|
return unless draft? && expired?
|
||||||
|
update!(status: "expired")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Increment payment attempt counter
|
||||||
|
def increment_payment_attempt!
|
||||||
|
update!(
|
||||||
|
payment_attempts: payment_attempts + 1,
|
||||||
|
last_payment_attempt_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if draft is about to expire (within 5 minutes)
|
||||||
|
def expiring_soon?
|
||||||
|
return false unless draft? && expires_at.present?
|
||||||
|
expires_at <= 5.minutes.from_now
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark order as paid and activate all tickets
|
||||||
|
def mark_as_paid!
|
||||||
|
transaction do
|
||||||
|
update!(status: "paid")
|
||||||
|
tickets.update_all(status: "active")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Send purchase confirmation email outside the transaction
|
||||||
|
# so that payment completion isn't affected by email failures
|
||||||
|
begin
|
||||||
|
TicketMailer.purchase_confirmation_order(self).deliver_now
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Failed to send purchase confirmation email for order #{id}: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
# Don't re-raise the error - payment should still succeed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Calculate total from ticket prices only (platform fee deducted from promoter payout)
|
||||||
|
def calculate_total!
|
||||||
|
ticket_total = tickets.sum(:price_cents)
|
||||||
|
update!(total_amount_cents: ticket_total)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
|
||||||
|
def platform_fee_cents
|
||||||
|
tickets.sum do |ticket|
|
||||||
|
fixed_fee = 50 # €0.50 in cents
|
||||||
|
percentage_fee = (ticket.price_cents * 0.015).to_i
|
||||||
|
fixed_fee + percentage_fee
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Promoter payout amount after platform fee deduction
|
||||||
|
def promoter_payout_cents
|
||||||
|
total_amount_cents - platform_fee_cents
|
||||||
|
end
|
||||||
|
|
||||||
|
def platform_fee_euros
|
||||||
|
platform_fee_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
def promoter_payout_euros
|
||||||
|
promoter_payout_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create Stripe invoice for accounting records
|
||||||
|
#
|
||||||
|
# This method creates a post-payment invoice in Stripe for accounting purposes
|
||||||
|
# It should only be called after the order has been paid
|
||||||
|
#
|
||||||
|
# @return [String, nil] The Stripe invoice ID or nil if creation failed
|
||||||
|
def create_stripe_invoice!
|
||||||
|
return nil unless status == "paid"
|
||||||
|
return @stripe_invoice_id if @stripe_invoice_id.present?
|
||||||
|
|
||||||
|
service = StripeInvoiceService.new(self)
|
||||||
|
stripe_invoice = service.create_post_payment_invoice
|
||||||
|
|
||||||
|
if stripe_invoice
|
||||||
|
@stripe_invoice_id = stripe_invoice.id
|
||||||
|
Rails.logger.info "Created Stripe invoice #{stripe_invoice.id} for order #{id}"
|
||||||
|
stripe_invoice.id
|
||||||
|
else
|
||||||
|
Rails.logger.error "Failed to create Stripe invoice for order #{id}: #{service.errors.join(', ')}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the Stripe invoice PDF URL if available
|
||||||
|
#
|
||||||
|
# @return [String, nil] The PDF URL or nil if not available
|
||||||
|
def stripe_invoice_pdf_url
|
||||||
|
return nil unless @stripe_invoice_id.present?
|
||||||
|
StripeInvoiceService.get_invoice_pdf_url(@stripe_invoice_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_expiry
|
||||||
|
return unless status == "draft"
|
||||||
|
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def draft?
|
||||||
|
status == "draft"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
class Ticket < ApplicationRecord
|
class Ticket < ApplicationRecord
|
||||||
# Associations
|
# === Associations ===
|
||||||
belongs_to :user
|
belongs_to :order
|
||||||
belongs_to :ticket_type
|
belongs_to :ticket_type
|
||||||
has_one :event, through: :ticket_type
|
has_one :event, through: :ticket_type
|
||||||
|
has_one :user, through: :order
|
||||||
|
|
||||||
# Validations
|
# === Validations ===
|
||||||
validates :qr_code, presence: true, uniqueness: true
|
validates :qr_code, presence: true, uniqueness: true
|
||||||
validates :user_id, presence: true
|
validates :order_id, presence: true
|
||||||
validates :ticket_type_id, presence: true
|
validates :ticket_type_id, presence: true
|
||||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
||||||
validates :status, presence: true, inclusion: { in: %w[active used expired refunded] }
|
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
||||||
validates :first_name, presence: true, if: :requires_names?
|
validates :first_name, presence: true
|
||||||
validates :last_name, presence: true, if: :requires_names?
|
validates :last_name, presence: true
|
||||||
|
|
||||||
|
# === Scopes ===
|
||||||
|
scope :draft, -> { where(status: "draft") }
|
||||||
|
scope :active, -> { where(status: "active") }
|
||||||
|
scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) }
|
||||||
|
|
||||||
before_validation :set_price_from_ticket_type, on: :create
|
before_validation :set_price_from_ticket_type, on: :create
|
||||||
before_validation :generate_qr_code, on: :create
|
before_validation :generate_qr_code, on: :create
|
||||||
@@ -26,9 +32,22 @@ class Ticket < ApplicationRecord
|
|||||||
price_cents / 100.0
|
price_cents / 100.0
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if names are required for this ticket type
|
# Delegate payment methods to order
|
||||||
def requires_names?
|
def can_retry_payment?
|
||||||
ticket_type&.requires_id
|
order.can_retry_payment?
|
||||||
|
end
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
order.expired?
|
||||||
|
end
|
||||||
|
|
||||||
|
def expiring_soon?
|
||||||
|
order.expiring_soon?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark ticket as expired if it's past expiry time
|
||||||
|
def expire_if_overdue!
|
||||||
|
order.expire_if_overdue!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -40,10 +59,18 @@ class Ticket < ApplicationRecord
|
|||||||
|
|
||||||
def generate_qr_code
|
def generate_qr_code
|
||||||
return if qr_code.present?
|
return if qr_code.present?
|
||||||
|
|
||||||
loop do
|
loop do
|
||||||
self.qr_code = SecureRandom.uuid
|
self.qr_code = SecureRandom.uuid
|
||||||
break unless Ticket.exists?(qr_code: qr_code)
|
break unless Ticket.exists?(qr_code: qr_code)
|
||||||
end
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to generate QR code for ticket: #{e.message}"
|
||||||
|
# Generate a simple fallback QR code
|
||||||
|
self.qr_code = "#{id || 'temp'}-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def draft?
|
||||||
|
status == "draft"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 }
|
||||||
@@ -10,11 +10,66 @@ class TicketType < ApplicationRecord
|
|||||||
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
validates :sale_start_at, presence: true
|
validates :sale_start_at, presence: true
|
||||||
validates :sale_end_at, presence: true
|
validates :sale_end_at, presence: true
|
||||||
validate :sale_end_after_start
|
|
||||||
validates :requires_id, inclusion: { in: [ true, false ] }
|
|
||||||
validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true
|
validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true
|
||||||
validates :event_id, presence: true
|
validates :event_id, presence: true
|
||||||
|
validates :requires_id, inclusion: { in: [ true, false ] }
|
||||||
|
|
||||||
|
# Custom validations
|
||||||
|
validate :sale_end_after_start
|
||||||
|
validate :sale_times_within_event_period
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :available_now, -> { where("sale_start_at <= ? AND sale_end_at >= ?", Time.current, Time.current) }
|
||||||
|
scope :upcoming, -> { where("sale_start_at > ?", Time.current) }
|
||||||
|
scope :expired, -> { where("sale_end_at < ?", Time.current) }
|
||||||
|
|
||||||
|
# Helper methods
|
||||||
|
def price_euros
|
||||||
|
return 0.0 if price_cents.nil?
|
||||||
|
price_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
def price_euros=(value)
|
||||||
|
self.price_cents = (value.to_f * 100).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def available?
|
||||||
|
return false if sale_start_at.nil? || sale_end_at.nil?
|
||||||
|
sale_start_at <= Time.current && sale_end_at >= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def sold_out?
|
||||||
|
return false if quantity.nil?
|
||||||
|
tickets.count >= quantity
|
||||||
|
end
|
||||||
|
|
||||||
|
def available_quantity
|
||||||
|
return 0 if quantity.nil?
|
||||||
|
[ quantity - tickets.count, 0 ].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def sales_status
|
||||||
|
return :draft if sale_start_at.nil? || sale_end_at.nil?
|
||||||
|
return :expired if sale_end_at < Time.current
|
||||||
|
return :upcoming if sale_start_at > Time.current
|
||||||
|
return :sold_out if sold_out?
|
||||||
|
:available
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_potential_revenue
|
||||||
|
return 0.0 if quantity.nil? || price_cents.nil?
|
||||||
|
quantity * price_euros
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_revenue
|
||||||
|
return 0.0 if price_cents.nil?
|
||||||
|
tickets.count * price_euros
|
||||||
|
end
|
||||||
|
|
||||||
|
def remaining_potential_revenue
|
||||||
|
return 0.0 if quantity.nil? || price_cents.nil?
|
||||||
|
available_quantity * price_euros
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -22,4 +77,13 @@ class TicketType < ApplicationRecord
|
|||||||
return unless sale_start_at && sale_end_at
|
return unless sale_start_at && sale_end_at
|
||||||
errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at
|
errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sale_times_within_event_period
|
||||||
|
return unless event&.start_time && sale_end_at
|
||||||
|
|
||||||
|
# Only enforce this restriction if booking during event is not allowed
|
||||||
|
unless event.allow_booking_during_event?
|
||||||
|
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,9 +22,30 @@ class User < ApplicationRecord
|
|||||||
# Relationships
|
# Relationships
|
||||||
has_many :events, dependent: :destroy
|
has_many :events, dependent: :destroy
|
||||||
has_many :tickets, dependent: :destroy
|
has_many :tickets, dependent: :destroy
|
||||||
|
has_many :orders, dependent: :destroy
|
||||||
|
|
||||||
# Validations
|
# Validations - allow reasonable name lengths
|
||||||
validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||||
validates :first_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||||
validates :company_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
|
||||||
|
|
||||||
|
# Onboarding methods
|
||||||
|
def needs_onboarding?
|
||||||
|
!onboarding_completed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete_onboarding!
|
||||||
|
update!(onboarding_completed: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Authorization methods
|
||||||
|
def can_manage_events?
|
||||||
|
# Only professional users can manage events
|
||||||
|
is_professionnal?
|
||||||
|
end
|
||||||
|
|
||||||
|
def promoter?
|
||||||
|
# Alias for can_manage_events? to make views more semantic
|
||||||
|
can_manage_events?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
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 ticket.ticket_type.name
|
pdf.text "Type de billet :", style: :bold
|
||||||
|
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,41 +76,222 @@ 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
|
||||||
|
|
||||||
qr_code_data = {
|
# Ensure all required data is present before generating QR code
|
||||||
ticket_id: ticket.id,
|
if ticket.qr_code.blank?
|
||||||
qr_code: ticket.qr_code,
|
raise "Ticket QR code is missing"
|
||||||
event_id: ticket.event.id,
|
end
|
||||||
user_id: ticket.user.id
|
|
||||||
}.to_json
|
# Build QR code data with safe association loading
|
||||||
|
qr_code_data = build_qr_code_data(ticket)
|
||||||
|
|
||||||
|
# Validate QR code data before creating QR code
|
||||||
|
if qr_code_data.blank? || qr_code_data == "{}"
|
||||||
|
Rails.logger.error "QR code data is empty: ticket_id=#{ticket.id}, qr_code=#{ticket.qr_code}, event_id=#{ticket.ticket_type&.event_id}, user_id=#{ticket.order&.user_id}"
|
||||||
|
raise "QR code data is empty or invalid"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensure qr_code_data is a proper string for QR code generation
|
||||||
|
unless qr_code_data.is_a?(String) && qr_code_data.length > 2
|
||||||
|
Rails.logger.error "QR code data is not a valid string: #{qr_code_data.inspect} (class: #{qr_code_data.class})"
|
||||||
|
raise "QR code data must be a valid string"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate QR code - prawn-qrcode expects the data string directly
|
||||||
|
pdf.print_qr_code(qr_code_data, extent: 120, align: :center)
|
||||||
|
|
||||||
qrcode = RQRCode::QRCode.new(qr_code_data)
|
|
||||||
pdf.print_qr_code(qrcode, 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
|
||||||
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
|
||||||
|
|||||||
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,41 +1,92 @@
|
|||||||
<div class="footer-content">
|
<div class="grid gap-8 mb-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="footer-section">
|
<div>
|
||||||
<h3>Events</h3>
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
<ul class="footer-links">
|
<i data-lucide="info" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
<li><a href="#">Find Events</a></li>
|
À propos
|
||||||
<li><a href="#">Host an Event</a></li>
|
</h3>
|
||||||
<li><a href="#">Event Categories</a></li>
|
<ul class="space-y-3">
|
||||||
<li><a href="#">Premium Events</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 class="footer-section">
|
|
||||||
<h3>Community</h3>
|
<div>
|
||||||
<ul class="footer-links">
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
<li><a href="#">Join Us</a></li>
|
<i data-lucide="map-pin" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
<li><a href="#">Member Benefits</a></li>
|
Villes
|
||||||
<li><a href="#">Success Stories</a></li>
|
</h3>
|
||||||
<li><a href="#">Ambassador Program</a></li>
|
<ul class="space-y-3">
|
||||||
|
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
|
||||||
|
<i data-lucide="map" class="w-4 h-4 mr-2"></i>
|
||||||
|
Paris
|
||||||
|
</a></li>
|
||||||
|
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
|
||||||
|
<i data-lucide="map" class="w-4 h-4 mr-2"></i>
|
||||||
|
Lyon (bientôt)
|
||||||
|
</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-section">
|
|
||||||
<h3>Support</h3>
|
<div>
|
||||||
<ul class="footer-links">
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
<li><a href="#">Help Center</a></li>
|
<i data-lucide="calendar" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
<li><a href="#">Contact Us</a></li>
|
Événements
|
||||||
<li><a href="#">Safety Guidelines</a></li>
|
</h3>
|
||||||
<li><a href="#">Cancellation Policy</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-section">
|
|
||||||
<h3>Company</h3>
|
<div>
|
||||||
<ul class="footer-links">
|
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
|
||||||
<li><a href="#">About Aperonight</a></li>
|
<i data-lucide="help-circle" class="w-5 h-5 mr-2 text-yellow-400"></i>
|
||||||
<li><a href="#">Careers</a></li>
|
Support
|
||||||
<li><a href="#">Press & Media</a></li>
|
</h3>
|
||||||
<li><a href="#">Partner With Us</a></li>
|
<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="footer-bottom">
|
|
||||||
<p>© 2024 Aperonight. All rights reserved. • <a href="#" style="color: var(--color-accent-400);">Privacy Policy</a> • <a href="#" style="color: var(--color-accent-400);">Terms of Service</a></p>
|
<div class="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,92 +1,188 @@
|
|||||||
<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 x-data="{ open: false }" class="container mx-auto px-4 sm:px-6 lg:px-8">
|
<nav data-controller="header" class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between h-16">
|
<div class="flex items-center h-16 justify-between">
|
||||||
<!-- Logo & Navigation -->
|
<!-- Logo -->
|
||||||
<div class="flex items-center space-x-8">
|
<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 class="hidden sm:flex space-x-6">
|
<!-- Desktop Navigation -->
|
||||||
<%= link_to t("header.parties"), events_path,
|
<div class="hidden sm:flex items-center space-x-8 w-full justify-center">
|
||||||
class: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
<%= link_to events_path,
|
||||||
<%= link_to t("header.concerts"), "#",
|
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 %>
|
||||||
class: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
Événements & Afterworks
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to dashboard_path,
|
||||||
|
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
||||||
|
Tableau de bord
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if user_signed_in? && current_user.promoter? %>
|
||||||
|
<%= link_to new_promoter_event_path,
|
||||||
|
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
||||||
|
Créer un événement
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to promoter_events_path,
|
||||||
|
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
||||||
|
Mes événements
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- <%= link_to "#",
|
||||||
|
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
||||||
|
Concerts
|
||||||
|
<% end %> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Authentication -->
|
<!-- Authentication -->
|
||||||
<div class="flex items-center space-x-4">
|
<div class="hidden sm:flex items-center space-x-4">
|
||||||
<% if user_signed_in? %>
|
<% if user_signed_in? %>
|
||||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
|
<div class="relative" data-header-target="userMenuButton">
|
||||||
<button @click="open = !open"
|
<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>
|
||||||
|
|
||||||
<div x-show="open" x-transition
|
<!-- User Dropdown Menu -->
|
||||||
class="absolute right-0 mt-2 w-48 rounded-md shadow-lg 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">
|
||||||
<%= link_to t("header.profile"), edit_user_registration_path,
|
<div class="px-4 py-3 text-sm text-gray-900 border-b border-gray-100">
|
||||||
class: "block px-4 py-2 text-sm bg-black text-gray-100 hover:bg-purple-700 first:rounded-t-md" %>
|
<div class="font-semibold"><%= current_user.first_name || current_user.email %></div>
|
||||||
<%= link_to t("header.reservations"), "#",
|
<div class="text-gray-500"><%= current_user.email %></div>
|
||||||
class: "block px-4 py-2 text-sm bg-black text-gray-100 hover:bg-purple-700" %>
|
</div>
|
||||||
<%= link_to t("header.logout"), destroy_user_session_path,
|
<%= link_to "#",
|
||||||
data: { controller: "logout", action: "click->logout#signOut",
|
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
|
||||||
logout_url_value: destroy_user_session_path, redirect_url_value: "/", turbo: false },
|
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
|
||||||
class: "block px-4 py-2 text-sm bg-black text-gray-100 hover:bg-purple-700 last:rounded-b-md" %>
|
Réservations
|
||||||
|
<% end %>
|
||||||
|
<%= link_to settings_path,
|
||||||
|
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
|
||||||
|
<i data-lucide="user" class="w-4 h-4 mr-3"></i>
|
||||||
|
Profil
|
||||||
|
<% end %>
|
||||||
|
<%= link_to edit_user_registration_path,
|
||||||
|
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
|
||||||
|
<i data-lucide="key" class="w-4 h-4 mr-3"></i>
|
||||||
|
Sécurité
|
||||||
|
<% end %>
|
||||||
|
<div class="border-t border-gray-100">
|
||||||
|
<%= link_to destroy_user_session_path,
|
||||||
|
data: { controller: "logout", action: "click->logout#signOut", logout_url_value: destroy_user_session_path, login_url_value: new_user_session_path, turbo: false },
|
||||||
|
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
|
||||||
|
<i data-lucide="log-out" class="w-4 h-4 mr-3"></i>
|
||||||
|
Déconnexion
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="hidden sm:flex items-center space-x-4">
|
<%= link_to new_user_session_path,
|
||||||
<%= link_to t("header.login"), new_user_session_path,
|
class: "text-gray-700 hover:text-brand-primary px-6 py-2 rounded-full text-sm font-medium transition-colors duration-200 whitespace-nowrap" do %>
|
||||||
class: "bg-black text-gray-100 hover:text-purple-200 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
|
Se connecter
|
||||||
<%= link_to t("header.register"), new_user_registration_path,
|
<% end %>
|
||||||
class: "bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
|
<%= link_to new_user_registration_path,
|
||||||
</div>
|
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 %>
|
||||||
<!-- Mobile Menu Button -->
|
</div>
|
||||||
<button @click="open = !open" class="sm:hidden p-2 rounded-md text-neutral-300 hover:text-white hover:bg-purple-700">
|
|
||||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
<!-- Mobile menu button -->
|
||||||
<path :class="{ "hidden": open }" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
<div class="flex-shrink-0 sm:hidden">
|
||||||
<path :class="{ "hidden": !open }" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<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>
|
<i data-menu-icon="open" data-lucide="menu" class="w-6 h-6"></i>
|
||||||
|
<i data-menu-icon="close" data-lucide="x" class="w-6 h-6 hidden"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
<!-- Mobile Menu -->
|
||||||
<div :class="{ "block": open, "hidden": !open }" 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 bg-black 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 bg-black 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 bg-black 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 bg-black 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 bg-black 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 bg-black 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>
|
||||||
|
|||||||
145
app/views/components/_header.html.erb.old
Normal file
145
app/views/components/_header.html.erb.old
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<!--
|
||||||
|
Note: The logout method is handled by logout_controller
|
||||||
|
in app/javascript/controllers/logout_controller.js
|
||||||
|
-->
|
||||||
|
<nav x-data="{ open: false }" class="bg-black border-b border-gray-100">
|
||||||
|
<!-- Primary Navigation Menu -->
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="shrink-0 flex items-center">
|
||||||
|
<%= link_to "Cyanet", "test", class: "text-white" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Links -->
|
||||||
|
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<%= link_to "Espace client", "#",
|
||||||
|
class: "inline-flex
|
||||||
|
items-center px-1 pt-1 border-b-2 border-transparent
|
||||||
|
text-sm font-medium leading-5 text-gray-300
|
||||||
|
hover:text-gray-500 hover:border-gray-300 focus:outline-none
|
||||||
|
focus:text-gray-700 focus:border-gray-300 transition
|
||||||
|
duration-150 ease-in-out" %>
|
||||||
|
<!-- ./Dashboard -->
|
||||||
|
|
||||||
|
<!-- My services -->
|
||||||
|
<%= link_to "Mes services", "#",
|
||||||
|
class: "inline-flex
|
||||||
|
items-center px-1 pt-1 border-b-2 border-transparent
|
||||||
|
text-sm font-medium leading-5 text-gray-300
|
||||||
|
hover:text-gray-500 hover:border-gray-300 focus:outline-none
|
||||||
|
focus:text-gray-700 focus:border-gray-300 transition
|
||||||
|
duration-150 ease-in-out" %>
|
||||||
|
<!-- ./My services-->
|
||||||
|
|
||||||
|
<!-- My services -->
|
||||||
|
<%= link_to "Mes factures", "#",
|
||||||
|
class: "inline-flex
|
||||||
|
items-center px-1 pt-1 border-b-2 border-transparent
|
||||||
|
text-sm font-medium leading-5 text-gray-300
|
||||||
|
hover:text-gray-500 hover:border-gray-300 focus:outline-none
|
||||||
|
focus:text-gray-700 focus:border-gray-300 transition
|
||||||
|
duration-150 ease-in-out" %>
|
||||||
|
<!-- ./My services-->
|
||||||
|
|
||||||
|
<!-- Support -->
|
||||||
|
<%= link_to "Support", "#",
|
||||||
|
class: "inline-flex
|
||||||
|
items-center px-1 pt-1 border-b-2 border-transparent
|
||||||
|
text-sm font-medium leading-5 text-gray-300
|
||||||
|
hover:text-gray-500 hover:border-gray-300 focus:outline-none
|
||||||
|
focus:text-gray-700 focus:border-gray-300 transition
|
||||||
|
duration-150 ease-in-out" %>
|
||||||
|
<!-- ./Support-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Dropdown -->
|
||||||
|
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||||
|
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
|
||||||
|
<div @click="open = ! open">
|
||||||
|
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
||||||
|
<div>Mon profil</div>
|
||||||
|
<div class="ms-1">
|
||||||
|
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="open"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-75"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
|
class="absolute z-50 mt-2 w-48 rounded-md shadow-lg origin-top-right right-0"
|
||||||
|
style="display: none;"
|
||||||
|
@click="open = false">
|
||||||
|
<div class="rounded-md ring-1 ring-black ring-opacity-5 py-1 bg-white">
|
||||||
|
<%= link_to "Profil", "#", class: "block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out" %>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<%= link_to "Déconnexion", destroy_user_session_path,
|
||||||
|
data: {
|
||||||
|
controller: "logout",
|
||||||
|
action: "click->logout#signOut",
|
||||||
|
logout_url_value: destroy_user_session_path,
|
||||||
|
login_url_value: new_user_session_path,
|
||||||
|
turbo: false
|
||||||
|
},
|
||||||
|
class: "inline-block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out" %>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hamburger -->
|
||||||
|
<div class="-me-2 flex items-center sm:hidden">
|
||||||
|
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
|
||||||
|
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path :class="{ 'hidden': open, 'inline-flex': !open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
<path :class="{ 'hidden': !open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Responsive Navigation Menu -->
|
||||||
|
<div :class="{ 'block': open, 'hidden': !open }" class="hidden sm:hidden">
|
||||||
|
<div class="pt-2 pb-3 space-y-1">
|
||||||
|
<%= link_to "Dashboard", "test", class: "block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-400 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Responsive Settings Options -->
|
||||||
|
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||||
|
<div class="px-4">
|
||||||
|
<div class="font-medium text-base text-gray-800">Test</div>
|
||||||
|
<div class="font-medium text-sm text-gray-500">Test</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 space-y-1">
|
||||||
|
<%= link_to "Profile", "test", class: "block w-full pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-400 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" %>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<%= link_to "Déconnexion", destroy_user_session_path,
|
||||||
|
data: {
|
||||||
|
controller: "logout",
|
||||||
|
action: "click->logout#signOut",
|
||||||
|
logout_url_value: destroy_user_session_path,
|
||||||
|
login_url_value: new_user_session_path,
|
||||||
|
turbo: false
|
||||||
|
},
|
||||||
|
class: "block w-full pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-400 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -1,41 +1,4 @@
|
|||||||
<div class="bg-white rounded-xl border <%= sold_out ? "border-gray-200 opacity-75" : "border-purple-200" %> shadow-sm overflow-hidden"
|
<div class="bg-white rounded-xl border <%= sold_out ? "border-gray-200 opacity-75" : "border-purple-200" %> shadow-sm overflow-hidden">
|
||||||
x-data="{
|
|
||||||
quantity: 0,
|
|
||||||
max: <%= remaining %>,
|
|
||||||
ticketTypeId: <%= id %>,
|
|
||||||
name: '<%= name %>',
|
|
||||||
price: <%= price_cents %>
|
|
||||||
}"
|
|
||||||
x-init="
|
|
||||||
$watch('quantity', (value) => {
|
|
||||||
$dispatch('update-cart-item', {
|
|
||||||
ticketTypeId: ticketTypeId,
|
|
||||||
name: name,
|
|
||||||
price: price,
|
|
||||||
quantity: value,
|
|
||||||
max: max
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$nextTick(() => {
|
|
||||||
$dispatch('cart-initialized', { ticketTypeId: ticketTypeId });
|
|
||||||
});
|
|
||||||
"
|
|
||||||
@update-cart-item.window="
|
|
||||||
if ($event.detail.ticketTypeId === ticketTypeId) {
|
|
||||||
quantity = $event.detail.quantity;
|
|
||||||
}
|
|
||||||
"
|
|
||||||
@cart-change.window="
|
|
||||||
if (!$event.detail.cart[ticketTypeId]) {
|
|
||||||
quantity = 0;
|
|
||||||
}
|
|
||||||
"
|
|
||||||
@restore-cart.window="
|
|
||||||
if ($event.detail.cart[ticketTypeId]) {
|
|
||||||
quantity = $event.detail.cart[ticketTypeId].quantity;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -53,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>
|
||||||
@@ -72,27 +31,31 @@
|
|||||||
<div class="flex items-center space-x-2 order-1 sm:order-2">
|
<div class="flex items-center space-x-2 order-1 sm:order-2">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
||||||
@click="quantity = Math.max(0, quantity - 1)"
|
data-action="ticket-selection#decrement"
|
||||||
:disabled="quantity <= 0">
|
data-target="<%= id %>">
|
||||||
<span class="text-gray-600 font-bold">-</span>
|
<span class="text-gray-600 font-bold">-</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
|
id="ticket_quantity_<%= id %>"
|
||||||
|
name="cart[<%= id %>][quantity]"
|
||||||
min="0"
|
min="0"
|
||||||
:max="max"
|
max="<%= remaining %>"
|
||||||
x-model="quantity"
|
value="0"
|
||||||
class="w-12 text-center border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1">
|
class="w-12 text-center border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1 ticket-quantity-input"
|
||||||
|
data-ticket-selection-target="quantityInput"
|
||||||
|
data-action="change->ticket-selection#updateQuantity"
|
||||||
|
data-price="<%= price_cents %>"
|
||||||
|
data-target="<%= id %>">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
||||||
@click="quantity = Math.min(max, quantity + 1)"
|
data-action="ticket-selection#increment"
|
||||||
:disabled="quantity >= max">
|
data-target="<%= id %>">
|
||||||
<span class="text-gray-600 font-bold">+</span>
|
<span class="text-gray-600 font-bold">+</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="text-sm text-gray-500 font-medium order-1 sm:order-2">
|
<div class="text-sm text-gray-500 font-medium 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="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>
|
||||||
|
<%= f.label :email, class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email),
|
||||||
|
placeholder: "votre@email.com",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="pt-4">
|
||||||
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %>
|
<%= 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 %>
|
||||||
<div class="mt-1">
|
<i data-lucide="send" class="w-4 h-4 mr-2"></i>
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email),
|
<%= t('devise.confirmations.new.submit') %>
|
||||||
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",
|
<% end %>
|
||||||
placeholder: "Email" %>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Additional Links -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous 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>
|
|
||||||
<%= f.submit t('devise.confirmations.new.submit'),
|
|
||||||
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
|
||||||
</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="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 -->
|
||||||
<%= f.hidden_field :reset_password_token %>
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: "space-y-6" }) do |f| %>
|
||||||
|
<%= f.hidden_field :reset_password_token %>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-5">
|
||||||
<div>
|
<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 %>
|
||||||
|
<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",
|
||||||
|
placeholder: "Votre nouveau mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :password_confirmation, t('devise.passwords.edit.confirm_new_password'), class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
||||||
|
placeholder: "Confirmez votre nouveau mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
|
||||||
|
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
|
||||||
|
<%= t('devise.passwords.edit.submit') %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= 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" %>
|
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div>
|
<!-- Additional Links -->
|
||||||
<%= f.label :password_confirmation, t('devise.passwords.edit.confirm_new_password'), class: "block text-sm font-medium text-neutral-700" %>
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
<div class="text-center">
|
||||||
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" %>
|
<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 class="actions">
|
|
||||||
<%= f.submit t('devise.passwords.edit.submit'),
|
|
||||||
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,46 +1,52 @@
|
|||||||
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-md w-full space-y-8">
|
<div 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>
|
||||||
<%= t('devise.passwords.new.title') %>
|
<p class="mt-2 text-gray-600">
|
||||||
</h2>
|
Entrez votre adresse email ci-dessous et nous vous enverrons un lien pour réinitialiser votre mot de passe.
|
||||||
<p class="mt-2 text-center text-sm text-neutral-600">
|
|
||||||
<%= t('devise.passwords.new.description') %>
|
|
||||||
</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>
|
||||||
|
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||||
|
placeholder: "votre@email.com",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="pt-4">
|
||||||
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %>
|
<%= 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 %>
|
||||||
<div class="mt-1">
|
<i data-lucide="send" class="w-4 h-4 mr-2"></i>
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
Envoyer le lien de réinitialisation
|
||||||
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",
|
<% end %>
|
||||||
placeholder: t('devise.passwords.new.email_placeholder') %>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Additional Links -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous 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>
|
|
||||||
<%= f.submit t('devise.passwords.new.submit'),
|
|
||||||
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-0 flex items-center">
|
|
||||||
<div class="w-full border-t border-neutral-300"></div>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex justify-center text-sm">
|
|
||||||
<span class="px-2 bg-neutral-50 text-neutral-600"> <%= t('devise.sessions.new.continue_with') %> </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
@@ -1,70 +1,111 @@
|
|||||||
<div class="min-h-screen flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-2xl w-full space-y-8">
|
<div class="max-w-2xl mx-auto space-y-8">
|
||||||
<div>
|
<!-- Breadcrumb -->
|
||||||
<%= link_to "/" do %>
|
<%= render 'components/breadcrumb', crumbs: [
|
||||||
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
|
{ name: 'Accueil', path: root_path },
|
||||||
<% end %>
|
{ name: 'Paramètres', path: settings_path },
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
|
{ name: 'Modifier le compte', path: nil }
|
||||||
Modifier votre compte
|
] %>
|
||||||
</h2>
|
|
||||||
<p class="mt-2 text-center text-sm text-neutral-600">
|
|
||||||
Gérez vos informations et préférences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: "mt-8 space-y-6" }) do |f| %>
|
<!-- Header -->
|
||||||
<%= render "devise/shared/error_messages", resource: resource %>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %>
|
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
|
|
||||||
<div class="text-sm text-neutral-600">
|
|
||||||
En attente de confirmation pour : <%= resource.unconfirmed_email %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-medium text-neutral-700" %>
|
|
||||||
<i class="text-sm text-neutral-500">(laissez vide si vous ne souhaitez pas le changer)</i>
|
|
||||||
<%= f.password_field :password, autocomplete: "new-password",
|
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= f.label :password_confirmation, t('devise.registrations.edit.confirm_new_password'), class: "block text-sm font-medium text-neutral-700" %>
|
|
||||||
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= f.label :current_password, t('devise.registrations.edit.current_password'), class: "block text-sm font-medium text-neutral-700" %>
|
|
||||||
<i class="text-sm text-neutral-500">(<%= t('devise.registrations.edit.current_password_required') %>)</i>
|
|
||||||
<%= f.password_field :current_password, autocomplete: "current-password",
|
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<%= f.submit t('devise.registrations.edit.update'),
|
|
||||||
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<h3 class="text-center text-lg font-medium text-neutral-900"><%= t('devise.registrations.edit.delete_account') %></h3>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-neutral-600">
|
<%= link_to "/" do %>
|
||||||
<%= t('devise.registrations.edit.unhappy') %> <%= button_to t('devise.registrations.edit.delete_account'), registration_path(resource_name),
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6 mx-auto">
|
||||||
data: { confirm: t('devise.registrations.edit.confirm_delete'), turbo_confirm: t('devise.registrations.edit.confirm_delete') },
|
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
|
||||||
method: :delete,
|
</div>
|
||||||
class: "font-medium text-red-600 hover:text-red-500" %>
|
<% end %>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">Modifier vos informations de sécurité</h2>
|
||||||
|
<p class="mt-2 text-gray-600">
|
||||||
|
Gérez vos informations et préférences de sécurité
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= link_to t('devise.registrations.edit.back'), :back, class: "text-center block text-purple-600 hover:text-purple-500" %>
|
<!-- Profile Form -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-6">Informations du compte</h3>
|
||||||
|
|
||||||
|
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: "space-y-6" }) do |f| %>
|
||||||
|
<%= render "devise/shared/error_messages", resource: resource %>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<%= f.label :email, class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
|
||||||
|
<div class="text-sm text-gray-600 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5 text-yellow-500 inline mr-2"></i>
|
||||||
|
En attente de confirmation pour : <%= resource.unconfirmed_email %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :current_password, "Mot de passe actuel", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="key" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :current_password, autocomplete: "current-password",
|
||||||
|
placeholder: "Requis pour confirmer vos changements",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Requis pour confirmer vos changements</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password, autocomplete: "new-password",
|
||||||
|
placeholder: "Laisser vide si vous ne souhaitez pas le changer",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
<% if @minimum_password_length %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500"><%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :password_confirmation, "Confirmer le nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
||||||
|
placeholder: "Confirmez votre nouveau mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
|
||||||
|
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
|
||||||
|
Mettre à jour
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# render "components/delete_account" %>
|
||||||
|
|
||||||
|
<!-- Back Link -->
|
||||||
|
<div class="text-center">
|
||||||
|
<%= link_to :back, class: "inline-flex items-center text-purple-600 hover:text-purple-500 transition-colors" do %>
|
||||||
|
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||||
|
Retour
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,91 @@
|
|||||||
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-md w-full space-y-8">
|
<div 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">Créer un compte</h2>
|
||||||
<%= t('devise.registrations.new.title') %>
|
<p class="mt-2 text-gray-600">
|
||||||
</h2>
|
ou <a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">se connecter à votre compte existant</a>
|
||||||
<p class="mt-2 text-center text-sm text-neutral-600">
|
|
||||||
<%= t('devise.registrations.new.or') %>
|
|
||||||
<a href="<%= new_user_session_path %>" class="font-medium text-purple-600 hover:text-purple-500">
|
|
||||||
<%= t('devise.registrations.new.sign_in_link') %>
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "mt-8 space-y-6" }) do |f| %>
|
<!-- Form -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "space-y-6" }) do |f| %>
|
||||||
|
<%= render "devise/shared/error_messages", resource: resource %>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||||
|
placeholder: "votre@email.com",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<div>
|
<%= f.label :password, "Mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %>
|
<% if @minimum_password_length %>
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
<p class="text-xs text-gray-500 mb-2"><%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %></p>
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
<% end %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password, autocomplete: "new-password",
|
||||||
|
placeholder: "Votre mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :password_confirmation, "Confirmation du mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
||||||
|
placeholder: "Confirmez votre mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="pt-4">
|
||||||
<%= f.label :password, class: "block text-sm font-medium text-neutral-700" %>
|
<%= 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 %>
|
||||||
<% if @minimum_password_length %>
|
<i data-lucide="user-plus" class="w-4 h-4 mr-2"></i>
|
||||||
<em class="text-sm text-neutral-500">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</em>
|
Créer un compte
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= f.password_field :password, autocomplete: "new-password",
|
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div>
|
<!-- Additional Links -->
|
||||||
<%= f.label :password_confirmation, class: "block text-sm font-medium text-neutral-700" %>
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
<div class="text-center">
|
||||||
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous avez déjà un compte?
|
||||||
|
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<!-- Footer -->
|
||||||
<%= f.submit t('devise.registrations.new.sign_up'), class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
|
<div class="text-center">
|
||||||
</div>
|
<p class="text-xs text-gray-500">
|
||||||
<% end %>
|
En créant un compte, vous acceptez nos
|
||||||
|
<a href="#" class="text-purple-600 hover:text-purple-500">conditions d'utilisation</a>
|
||||||
<div class="mt-6">
|
et notre
|
||||||
<div class="relative">
|
<a href="#" class="text-purple-600 hover:text-purple-500">politique de confidentialité</a>.
|
||||||
<div class="absolute inset-0 flex items-center">
|
</p>
|
||||||
<div class="w-full border-t border-neutral-300"></div>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex justify-center text-sm">
|
|
||||||
<span class="px-2 bg-neutral-50 text-neutral-600"> <%= t('devise.registrations.new.continue_with') %> </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<%= render "devise/shared/links" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,64 +1,74 @@
|
|||||||
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-md w-full space-y-8">
|
<div 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">Connexion à votre compte</h2>
|
||||||
<%= t('devise.sessions.new.title') %>
|
<p class="mt-2 text-gray-600">
|
||||||
</h2>
|
ou <a href="<%= new_user_registration_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">créer un compte</a>
|
||||||
<p class="mt-2 text-center text-sm text-neutral-600">
|
|
||||||
<%= t('devise.sessions.new.or') %>
|
|
||||||
<a href="<%= new_user_registration_path %>" class="font-medium text-purple-600 hover:text-purple-500">
|
|
||||||
<%= t('devise.sessions.new.sign_up_link') %>
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "mt-8 space-y-6" }) do |f| %>
|
<!-- Form -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
<div class="rounded-md shadow-sm -space-y-px">
|
<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "space-y-6" }) do |f| %>
|
||||||
<div class="field">
|
<div class="space-y-5">
|
||||||
<%= f.label :email, class: "sr-only" %>
|
<div>
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
class: "appearance-none rounded-none relative block w-full px-3 py-2 border border-neutral-300 placeholder-neutral-500 text-neutral-900 bg-white rounded-t-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm",
|
<div class="relative">
|
||||||
placeholder: t('devise.sessions.new.email_placeholder') %>
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
</div>
|
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||||
<%= f.label :password, class: "sr-only" %>
|
placeholder: "votre@email.com",
|
||||||
<%= f.password_field :password, autocomplete: "current-password",
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
class: "appearance-none rounded-none relative block w-full px-3 py-2 border border-neutral-300 placeholder-neutral-500 text-neutral-900 bg-white rounded-b-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm",
|
</div>
|
||||||
placeholder: t('devise.sessions.new.password_placeholder') %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if devise_mapping.rememberable? %>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<%= f.check_box :remember_me, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-neutral-300 rounded bg-white" %>
|
|
||||||
<label for="user_remember_me" class="ml-2 block text-sm text-neutral-700"> <%= t('devise.sessions.new.remember_me') %> </label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :password, "Mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password, autocomplete: "current-password",
|
||||||
|
placeholder: "Votre mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if devise_mapping.rememberable? %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= f.check_box :remember_me, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded bg-white" %>
|
||||||
|
<%= f.label :remember_me, "Se souvenir de moi", class: "ml-2 block text-sm text-gray-700" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
|
||||||
|
<i data-lucide="log-in" class="w-4 h-4 mr-2"></i>
|
||||||
|
Se connecter
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="actions">
|
<!-- Additional Links -->
|
||||||
<%= f.submit t('devise.sessions.new.sign_in'), class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
</div>
|
<div class="text-center space-y-3">
|
||||||
<% end %>
|
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
|
||||||
|
<%= link_to "Mot de passe oublié ?", new_password_path(resource_name), class: "text-sm text-purple-600 hover:text-purple-500 transition-colors" %>
|
||||||
<div class="mt-6">
|
<% end %>
|
||||||
<div class="relative">
|
<p class="text-sm text-gray-600">
|
||||||
<div class="absolute inset-0 flex items-center">
|
Vous n'avez pas encore de compte?
|
||||||
<div class="w-full border-t border-neutral-300"></div>
|
<a href="<%= new_user_registration_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">S'inscrire</a>
|
||||||
</div>
|
</p>
|
||||||
<div class="relative flex justify-center text-sm">
|
|
||||||
<span class="px-2 bg-neutral-50 text-neutral-600"> <%= t('devise.sessions.new.continue_with') %> </span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
<% if resource.errors.any? %>
|
<% if resource.errors.any? %>
|
||||||
<% resource.errors.full_messages.each do |message| %>
|
<div class="bg-red-50 border border-red-200 text-red-800 rounded-xl p-4 mb-6" data-controller="flash-message">
|
||||||
<% flash.now[:error] = message %>
|
<div class="flex items-start">
|
||||||
<% end %>
|
<div class="flex-shrink-0">
|
||||||
<% end %>
|
<i data-lucide="x-circle" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<h3 class="text-sm font-medium mb-2">Veuillez corriger les erreurs suivantes :</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<% resource.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button data-action="click->flash-message#close" class="bg-transparent border-none cursor-pointer p-1 text-inherit opacity-70 transition-opacity duration-200">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -1,39 +1,58 @@
|
|||||||
<div class="mt-4 space-y-4">
|
<div class="mt-6">
|
||||||
<%- if controller_name != "sessions" %>
|
<div class="relative">
|
||||||
<div class="w-full flex justify-center py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
|
<div class="absolute inset-0 flex items-center">
|
||||||
<%= link_to t('devise.shared.links.sign_in'), new_session_path(resource_name), class: "block" %>
|
<div class="w-full border-t border-gray-200"></div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-2 bg-white text-gray-500">Ou continuer avec</span>
|
||||||
<%- if devise_mapping.registerable? && controller_name != "registrations" %>
|
|
||||||
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
|
|
||||||
<%= link_to t('devise.shared.links.sign_up'), new_registration_path(resource_name), class: "block" %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
|
|
||||||
<%- if devise_mapping.recoverable? && controller_name != "passwords" && controller_name != "registrations" %>
|
<div class="mt-6 grid grid-cols-2 gap-3">
|
||||||
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
|
<%- if controller_name != "sessions" %>
|
||||||
<%= link_to t('devise.shared.links.forgot_password'), new_password_path(resource_name), class: "block" %>
|
<%= link_to new_session_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
|
||||||
</div>
|
<i data-lucide="log-in" class="w-4 h-4 mr-2"></i>
|
||||||
<% end %>
|
Se connecter
|
||||||
|
<% end %>
|
||||||
<%- if devise_mapping.confirmable? && controller_name != "confirmations" %>
|
|
||||||
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
|
|
||||||
<%= link_to t('devise.shared.links.confirmation_instructions'), new_confirmation_path(resource_name), class: "block" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != "unlocks" %>
|
|
||||||
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
|
|
||||||
<%= link_to t('devise.shared.links.unlock_instructions'), new_unlock_path(resource_name), class: "block" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%- if devise_mapping.omniauthable? %>
|
|
||||||
<%- resource_class.omniauth_providers.each do |provider| %>
|
|
||||||
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
|
|
||||||
<%= button_to t('devise.shared.links.sign_in_with', provider: OmniAuth::Utils.camelize(provider)), omniauth_authorize_path(resource_name, provider), data: { turbo: false }, class: "block" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</div>
|
<%- if devise_mapping.registerable? && controller_name != "registrations" %>
|
||||||
|
<%= link_to new_registration_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
|
||||||
|
<i data-lucide="user-plus" class="w-4 h-4 mr-2"></i>
|
||||||
|
S'inscrire
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-3">
|
||||||
|
<%- if devise_mapping.recoverable? && controller_name != "passwords" && controller_name != "registrations" %>
|
||||||
|
<%= link_to new_password_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
|
||||||
|
<i data-lucide="help-circle" class="w-4 h-4 mr-2"></i>
|
||||||
|
Mot de passe oublié ?
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%- if devise_mapping.confirmable? && controller_name != "confirmations" %>
|
||||||
|
<%= link_to new_confirmation_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
|
||||||
|
<i data-lucide="mail" class="w-4 h-4 mr-2"></i>
|
||||||
|
Renvoyer le lien de confirmation
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != "unlocks" %>
|
||||||
|
<%= link_to new_unlock_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
|
||||||
|
<i data-lucide="unlock" class="w-4 h-4 mr-2"></i>
|
||||||
|
Renvoyer le lien de déblocage
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%- if devise_mapping.omniauthable? %>
|
||||||
|
<%- resource_class.omniauth_providers.each do |provider| %>
|
||||||
|
<%= button_to omniauth_authorize_path(resource_name, provider), data: { turbo: false }, class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
|
||||||
|
<i data-lucide="external-link" class="w-4 h-4 mr-2"></i>
|
||||||
|
Se connecter avec <%= OmniAuth::Utils.camelize(provider) %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,15 +1,52 @@
|
|||||||
<h2>Resend unlock instructions</h2>
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
|
<%= link_to "/" do %>
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
|
||||||
|
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">Renvoyer les instructions de déverrouillage</h2>
|
||||||
|
<p class="mt-2 text-gray-600">
|
||||||
|
Entrez votre adresse email et nous vous enverrons les instructions de déverrouillage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
|
<!-- Form -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: "space-y-6" }) do |f| %>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||||
|
placeholder: "votre@email.com",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="pt-4">
|
||||||
<%= f.label :email %><br />
|
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
|
<i data-lucide="send" class="w-4 h-4 mr-2"></i>
|
||||||
|
Renvoyer les instructions de déverrouillage
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Additional Links -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous 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 class="actions">
|
|
||||||
<%= f.submit "Resend unlock instructions" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= render "devise/shared/links" %>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
|
||||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<!-- Breadcrumb -->
|
|
||||||
<nav class="mb-6" aria-label="Breadcrumb">
|
|
||||||
<ol class="flex items-center space-x-2 text-sm">
|
|
||||||
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
|
||||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Accueil
|
|
||||||
<% end %>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
|
||||||
Événements
|
|
||||||
<% end %>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<%= link_to event_path(@event.slug, @event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
|
||||||
<%= @event.name %>
|
|
||||||
<% end %>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<li class="font-medium text-gray-900" aria-current="page">
|
|
||||||
Informations des participants
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
|
||||||
<div class="p-6 md:p-8">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Informations des participants</h1>
|
|
||||||
<p class="text-gray-600">Veuillez fournir les prénoms et noms des personnes qui utiliseront les billets.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= form_with url: event_process_names_path(@event.slug, @event), method: :post, local: true, class: "space-y-6" do |form| %>
|
|
||||||
<% if @tickets_needing_names.any? %>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900">Billets nécessitant une identification</h2>
|
|
||||||
<p class="text-gray-600 mb-4">Les billets suivants nécessitent que vous indiquiez le prénom et le nom de chaque participant.</p>
|
|
||||||
|
|
||||||
<% @tickets_needing_names.each_with_index do |ticket, index| %>
|
|
||||||
<div class="bg-gray-50 rounded-xl p-6">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4"><%= ticket[:ticket_type_name] %> #<%= index + 1 %></h3>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<%= form.label "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][first_name]", "Prénom", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
|
||||||
<%= form.text_field "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][first_name]",
|
|
||||||
required: true,
|
|
||||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][last_name]", "Nom", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
|
||||||
<%= form.text_field "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][last_name]",
|
|
||||||
required: true,
|
|
||||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 pt-6">
|
|
||||||
<%= link_to "Retour", event_path(@event.slug, @event), class: "px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 text-center font-medium transition-colors" %>
|
|
||||||
<%= form.submit "Procéder au paiement", class: "flex-1 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-3 px-6 rounded-xl shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,177 +1,113 @@
|
|||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="flex justify-between items-center my-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1>
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
<%= @events.total_count %>
|
|
||||||
événements trouvés
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav class="mb-6" aria-label="Breadcrumb">
|
<%= render 'components/breadcrumb', crumbs: [
|
||||||
<ol class="flex items-center space-x-2 text-sm">
|
{ name: 'Accueil', path: root_path },
|
||||||
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
{ name: 'Événements', path: events_path }
|
||||||
<svg
|
] %>
|
||||||
class="w-4 h-4 inline-block mr-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Accueil
|
|
||||||
<% end %>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 text-gray-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
|
||||||
Événements
|
|
||||||
<% end %>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<header class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900">Événements à venir</h1>
|
||||||
|
<p class="text-gray-600 mt-2">Découvrez les meilleurs afterworks et événements de Paris</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-100 text-purple-800 px-4 py-2 rounded-full text-sm font-medium">
|
||||||
|
<%= @events.total_count %> événements trouvés
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Events Grid -->
|
||||||
<% if @events.any? %>
|
<% if @events.any? %>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
<% @events.each do |event| %>
|
<% @events.each do |event| %>
|
||||||
<div
|
<article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1">
|
||||||
class="
|
<%= link_to event_path(event.slug, event), class: "block" do %>
|
||||||
bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all
|
<% if event.image.present? %>
|
||||||
duration-300 transform hover:-translate-y-1
|
<div class="relative overflow-hidden aspect-[4/3]">
|
||||||
"
|
<img
|
||||||
>
|
src="<%= event.image %>"
|
||||||
<% if event.image.present? %>
|
alt="<%= event.name %>"
|
||||||
<div class="h-48 overflow-hidden">
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
<%= image_tag event.image, class: "w-full h-full object-cover" %>
|
>
|
||||||
</div>
|
<!-- Event featured badge -->
|
||||||
<% else %>
|
<% if event.featured? %>
|
||||||
<div
|
<div class="absolute top-4 left-4">
|
||||||
class="
|
<span class="bg-yellow-400 text-gray-900 px-3 py-1 rounded-full text-sm font-medium shadow-lg">
|
||||||
h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center
|
★ En vedette
|
||||||
justify-center
|
</span>
|
||||||
"
|
</div>
|
||||||
>
|
<% end %>
|
||||||
<svg
|
<!-- Date badge -->
|
||||||
class="w-16 h-16 text-white opacity-80"
|
<div class="absolute bottom-4 right-4">
|
||||||
fill="none"
|
<span class="bg-white/90 backdrop-blur-sm text-gray-900 px-3 py-1 rounded-full text-sm font-bold shadow-lg">
|
||||||
stroke="currentColor"
|
<%= event.start_time.strftime("%d/%m") %>
|
||||||
viewBox="0 0 24 24"
|
</span>
|
||||||
>
|
</div>
|
||||||
<path
|
</div>
|
||||||
stroke-linecap="round"
|
<% else %>
|
||||||
stroke-linejoin="round"
|
<div class="relative overflow-hidden aspect-[4/3] bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||||
stroke-width="2"
|
<i data-lucide="calendar" class="w-16 h-16 text-white"></i>
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
<!-- Date badge -->
|
||||||
/>
|
<div class="absolute bottom-4 right-4">
|
||||||
</svg>
|
<span class="bg-white/90 backdrop-blur-sm text-gray-900 px-3 py-1 rounded-full text-sm font-bold shadow-lg">
|
||||||
</div>
|
<%= event.start_time.strftime("%d/%m") %>
|
||||||
<% end %>
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="mb-4">
|
||||||
<div>
|
<h2 class="text-xl font-bold text-gray-900 mb-2 group-hover:text-purple-600 transition-colors line-clamp-2"><%= event.name %></h2>
|
||||||
<h2 class="text-xl font-bold text-gray-900 line-clamp-1"><%= event.name %></h2>
|
<p class="text-sm text-gray-500 flex items-center">
|
||||||
<p class="text-xs text-gray-500 flex items-center mt-1">
|
<i data-lucide="map-pin" class="w-4 h-4 mr-2"></i>
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<%= event.venue_name.truncate(25) %>
|
||||||
<path
|
</p>
|
||||||
stroke-linecap="round"
|
<p class="text-sm text-gray-500 flex items-center mt-1">
|
||||||
stroke-linejoin="round"
|
<i data-lucide="clock" class="w-4 h-4 mr-2"></i>
|
||||||
stroke-width="2"
|
<%= l(event.start_time, format: '%A %d %B • %H:%M') %>
|
||||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<%= event.venue_name.truncate(20) %>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
class="
|
|
||||||
inline-flex items-center my-2 px-2.5 py-2 rounded-full text-xs font-medium
|
|
||||||
bg-purple-100 text-purple-800
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<%= event.start_time.strftime("%d/%m") %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<p class="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||||
<p class="text-gray-600 text-sm line-clamp-2"><%= event.description.truncate(100) %></p>
|
<%= event.description.truncate(100) %>
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center pt-4 border-t border-gray-100">
|
||||||
<div>
|
<div>
|
||||||
<% if event.ticket_types.any? %>
|
<% if event.ticket_types.any? %>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm font-semibold text-gray-900">
|
||||||
À partir de
|
À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€
|
||||||
<%= format_price(event.ticket_types.minimum(:price_cents)) %>€
|
</p>
|
||||||
</p>
|
<% else %>
|
||||||
<% else %>
|
<p class="text-sm text-gray-500">Pas de billets disponibles</p>
|
||||||
<p class="text-sm text-gray-500">Pas de billets disponibles</p>
|
<% end %>
|
||||||
<% end %>
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-flex items-center text-purple-600 font-medium text-sm group-hover:text-purple-700">
|
||||||
|
Voir détails
|
||||||
|
<i data-lucide="arrow-right" class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= link_to event_path(event.slug, event), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200" do %>
|
|
||||||
Détails
|
|
||||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
</div>
|
</article>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex justify-center">
|
<!-- Pagination -->
|
||||||
|
<div class="flex justify-center mt-12">
|
||||||
<%= paginate @events, theme: "tailwind" %>
|
<%= paginate @events, theme: "tailwind" %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
<!-- Empty State -->
|
||||||
<div class="text-center py-16">
|
<div class="text-center py-16">
|
||||||
<div class="mx-auto max-w-md">
|
<div class="w-24 h-24 bg-gradient-to-br from-purple-100 to-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<div
|
<i data-lucide="calendar-x" class="w-12 h-12 text-purple-600"></i>
|
||||||
class="
|
|
||||||
w-24 h-24 mx-auto bg-gradient-to-r from-purple-100 to-indigo-100 rounded-full
|
|
||||||
flex items-center justify-center mb-6
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-12 h-12 text-purple-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun événement disponible</h3>
|
|
||||||
<p class="text-gray-500 mb-6">Il n'y a aucun événement à venir pour le moment.</p>
|
|
||||||
<%= link_to "Retour à l'accueil",
|
|
||||||
root_path,
|
|
||||||
class:
|
|
||||||
"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 mb-4">Aucun événement disponible</h3>
|
||||||
|
<p class="text-gray-600 mb-8 max-w-md mx-auto">Il n'y a aucun événement à venir pour le moment. Revenez bientôt pour découvrir de nouvelles sorties!</p>
|
||||||
|
<%= link_to "<i data-lucide=\"home\" class=\"w-4 h-4 mr-2\"></i> Retour à l'accueil".html_safe, root_path, class: "inline-flex items-center bg-purple-600 text-white px-6 py-3 rounded-full font-semibold hover:bg-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user