Compare commits
26 Commits
3ce8096d63
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a0336ae5 | ||
|
|
fce84ff4a6 | ||
|
|
823ca68119 | ||
|
|
7a65e7a1f4 | ||
|
|
cd9e914713 | ||
|
|
18cc2e66af | ||
|
|
2d216b907e | ||
|
|
a97e4f4469 | ||
|
|
f53e271553 | ||
|
|
c30c2640ec | ||
|
|
a036e78897 | ||
|
|
632d2c6086 | ||
|
|
b6dd07459e | ||
|
|
42c64e9f97 | ||
|
|
826ed1ce07 | ||
|
|
935921f698 | ||
|
|
5b663be89f | ||
|
|
b87443f0e5 | ||
|
|
16cb8c78ce | ||
|
|
445e1570b4 | ||
|
|
ba4e76058e | ||
|
|
c9d4621aaf | ||
|
|
7b0164dfc6 | ||
|
|
3c1ae1643b | ||
|
|
3f0618829f | ||
|
|
71b2c5f2ed |
24
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Deploy NPM app
|
||||
run-name: ${{ gitea.actor }} is deploying to PROD servers.
|
||||
on: [ push, fork, pull ]
|
||||
|
||||
jobs:
|
||||
Deploy NPM:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: SSH to remote server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORD }}
|
||||
port: ${{ secrets.PORT }}
|
||||
script: |
|
||||
cd /home/zouklambadabcn.com/public_html/
|
||||
git pull
|
||||
npm install
|
||||
npm run build
|
||||
pm2 restart ZLB
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||
25
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# RTK — Token-Optimized CLI
|
||||
|
||||
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
|
||||
|
||||
## Rule
|
||||
|
||||
Always prefix shell commands with `rtk`:
|
||||
|
||||
```bash
|
||||
# Instead of: Use:
|
||||
git status rtk git status
|
||||
git log -10 rtk git log -10
|
||||
cargo test rtk cargo test
|
||||
docker ps rtk docker ps
|
||||
kubectl get pods rtk kubectl pods
|
||||
```
|
||||
|
||||
## Meta commands (use directly)
|
||||
|
||||
```bash
|
||||
rtk gain # Token savings dashboard
|
||||
rtk gain --history # Per-command savings history
|
||||
rtk discover # Find missed rtk opportunities
|
||||
rtk proxy <cmd> # Run raw (no filtering) but track usage
|
||||
```
|
||||
12
.github/hooks/rtk-rewrite.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "rtk hook copilot",
|
||||
"cwd": ".",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.github/workflows/node.js.yml
vendored
@@ -16,10 +16,9 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
node-version: [22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
|
||||
steps:
|
||||
- name: Test Summary
|
||||
uses: test-summary/action@v2
|
||||
|
||||
75
Jenkinsfile
vendored
@@ -1,75 +0,0 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
REMOTE_HOST = '192.168.1.102'
|
||||
REMOTE_DIR = '/home/zouklambadabcn.com/public_html'
|
||||
PM2_APP = 'ZLB'
|
||||
// Name of Jenkins Credentials (Username with private key) to SSH
|
||||
SSH_CREDS = 'ssh-remote' // <-- configure this in Jenkins Credentials
|
||||
}
|
||||
|
||||
options {
|
||||
ansiColor('xterm')
|
||||
timestamps()
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Deploy over SSH') {
|
||||
steps {
|
||||
script {
|
||||
// Validate Jenkins has the required credential
|
||||
withCredentials([sshUserPrivateKey(credentialsId: env.SSH_CREDS, keyFileVariable: 'SSH_KEY', usernameVariable: 'SSH_USER')]) {
|
||||
// Build the remote command to run
|
||||
def remoteCmd = """
|
||||
set -euo pipefail
|
||||
cd "${env.REMOTE_DIR}"
|
||||
echo "[$(date)] PWD=$(pwd) on ${HOSTNAME}"
|
||||
|
||||
# Ensure repo is cleanly updated
|
||||
git fetch --all --prune
|
||||
git reset --hard HEAD
|
||||
git pull --rebase --autostash || git pull
|
||||
|
||||
# Use npm if available, fallback to npx if needed
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm ci || npm install
|
||||
npm run build
|
||||
else
|
||||
npx --yes npm@latest ci || npx --yes npm@latest install
|
||||
npx --yes npm@latest run build
|
||||
fi
|
||||
|
||||
# Restart pm2 app
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
pm2 restart "${PM2_APP}" || pm2 start npm --name "${PM2_APP}" -- run start
|
||||
pm2 save || true
|
||||
else
|
||||
echo 'pm2 not found in PATH' >&2
|
||||
exit 1
|
||||
fi
|
||||
""".stripIndent()
|
||||
|
||||
// SSH options for non-interactive, secure connection
|
||||
def sshOpts = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes'
|
||||
|
||||
// Execute remote command via ssh
|
||||
sh label: 'Run remote deployment', script: "ssh -i \"${SSH_KEY}\" ${sshOpts} \"${SSH_USER}@${REMOTE_HOST}\" 'bash -lc '\''" + remoteCmd.replace("'", "'\''") + "'\'' '"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo 'Deployment completed successfully.'
|
||||
}
|
||||
failure {
|
||||
echo 'Deployment failed.'
|
||||
}
|
||||
always {
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
README.md
@@ -1,4 +1,26 @@
|
||||
Follow these steps:
|
||||
|
||||
<!-- README.md -->
|
||||
+ [](https://github.com/Ichitux/lambada-fiesta-live/actions/workflows/node.js.yml)
|
||||
|
||||
|
||||
# Project Name
|
||||
|
||||
ZoukLambadaBCN Beach Festival 2026 edition
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- Vite
|
||||
- TypeScript
|
||||
- React
|
||||
- shadcn-ui
|
||||
- Tailwind CSS
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository using the project's Git URL.
|
||||
2. Navigate to the project directory.
|
||||
3. Install the necessary dependencies.
|
||||
4. Start the development server with auto-reloading and an instant preview.
|
||||
|
||||
```sh
|
||||
# Step 1: Clone the repository using the project's Git URL.
|
||||
@@ -12,47 +34,3 @@ npm i
|
||||
|
||||
# Step 4: Start the development server with auto-reloading and an instant preview.
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Edit a file directly in GitHub**
|
||||
|
||||
- Navigate to the desired file(s).
|
||||
- Click the "Edit" button (pencil icon) at the top right of the file view.
|
||||
- Make your changes and commit the changes.
|
||||
|
||||
**Use GitHub Codespaces**
|
||||
|
||||
- Navigate to the main page of your repository.
|
||||
- Click on the "Code" button (green button) near the top right.
|
||||
- Select the "Codespaces" tab.
|
||||
- Click on "New codespace" to launch a new Codespace environment.
|
||||
- Edit files directly within the Codespace and commit and push your changes once you're done.
|
||||
|
||||
## What technologies are used for this project?
|
||||
|
||||
This project is built with:
|
||||
|
||||
- Vite
|
||||
- TypeScript
|
||||
- React
|
||||
- shadcn-ui
|
||||
- Tailwind CSS
|
||||
|
||||
## How can I deploy this project?
|
||||
```sh
|
||||
# Step 1: Clone the repository using the project's Git URL.
|
||||
git clone <YOUR_GIT_URL>
|
||||
|
||||
# Step 2: Navigate to the project directory.
|
||||
cd <YOUR_PROJECT_NAME>
|
||||
|
||||
# Step 3: Install the necessary dependencies.
|
||||
npm i
|
||||
|
||||
# Step 4: Start the development server with auto-reloading and an instant preview.
|
||||
npm run build
|
||||
|
||||
npm install -g serve
|
||||
|
||||
serve -s dist
|
||||
```
|
||||
|
||||
16
bun.lock
@@ -34,6 +34,8 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"baseline-browser-mapping": "^2.10.11",
|
||||
"caniuse-lite": "^1.0.30001781",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -494,6 +496,8 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
@@ -510,7 +514,7 @@
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
@@ -1122,6 +1126,10 @@
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"autoprefixer/caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="],
|
||||
@@ -1148,8 +1156,6 @@
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
@@ -1166,8 +1172,6 @@
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
@@ -1215,7 +1219,5 @@
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
2698
package-lock.json
generated
@@ -42,12 +42,15 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"baseline-browser-mapping": "^2.10.11",
|
||||
"caniuse-lite": "^1.0.30001781",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.35.0",
|
||||
"i18next": "^25.10.10",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -55,6 +58,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -78,13 +82,13 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^15.15.0",
|
||||
"jsdom": "^20.0.3",
|
||||
"jsdom": "^29.0.1",
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 27 KiB |
40
scale_fix.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dir = 'src/components';
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let original = content;
|
||||
|
||||
// Hero Section
|
||||
if (file === 'HeroSection.tsx') {
|
||||
content = content.replaceAll('text-5xl md:text-7xl lg:text-8xl', 'text-4xl md:text-6xl lg:text-8xl break-words');
|
||||
content = content.replaceAll('text-2xl md:text-4xl font-hero', 'text-xl md:text-3xl lg:text-4xl font-hero');
|
||||
} else {
|
||||
// Normal Sections Big Titles
|
||||
// Replace text-6xl md:text-7xl font-bold pt-4 pb-10 leading-[1.8] text-gradient
|
||||
content = content.replace(/text-6xl md:text-7xl/g, 'text-4xl md:text-5xl lg:text-7xl break-words');
|
||||
content = content.replace(/text-5xl md:text-6xl/g, 'text-3xl md:text-4xl lg:text-6xl break-words');
|
||||
|
||||
// Smaller sizes
|
||||
content = content.replace(/text-5xl pt-3 pb-6/g, 'text-3xl md:text-4xl lg:text-5xl pt-3 pb-6 break-words');
|
||||
content = content.replace(/text-4xl pt-3 pb-5/g, 'text-2xl md:text-3xl lg:text-4xl pt-3 pb-5');
|
||||
content = content.replace(/text-3xl pt-3 pb-5/g, 'text-xl md:text-2xl lg:text-3xl pt-3 pb-5');
|
||||
|
||||
// Footer / Nav
|
||||
content = content.replace(/text-4xl py-4/g, 'text-2xl md:text-3xl lg:text-4xl py-4');
|
||||
content = content.replace(/text-4xl py-2/g, 'text-2xl md:text-3xl lg:text-4xl py-2');
|
||||
content = content.replace(/text-2xl py-2/g, 'text-lg md:text-xl lg:text-2xl py-2');
|
||||
}
|
||||
|
||||
// General overflow catch-all: Ensure .container has overflow-hidden just in case?
|
||||
// No, adding break-words is better.
|
||||
|
||||
if (original !== content) {
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(`Fixed responsive sizes in ${file}`);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 319 KiB |
BIN
src/assets/gallery/gal1.jpg
Normal file
|
After Width: | Height: | Size: 396 KiB |
BIN
src/assets/gallery/gal2.jpg
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
src/assets/gallery/gal3.jpg
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
src/assets/gallery/gal4.jpg
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
src/assets/gallery/gal5.jpg
Normal file
|
After Width: | Height: | Size: 462 KiB |
BIN
src/assets/gallery/gal6.jpg
Normal file
|
After Width: | Height: | Size: 424 KiB |
BIN
src/assets/staff/djcathie.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
src/assets/staff/letialex.jpg
Normal file
|
After Width: | Height: | Size: 227 KiB |
@@ -1,63 +1,68 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { ABOUT_EVENT } from "@/data/event-data";
|
||||
import aboutImg from "@/assets/about-event.jpg";
|
||||
import { Music, Users, Sparkles, PartyPopper } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const iconMap = [Music, Users, Sparkles, PartyPopper];
|
||||
|
||||
const AboutSection = () => (
|
||||
<section id="about" className="section-padding bg-background">
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Imagen */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<img
|
||||
src={aboutImg}
|
||||
alt="Evento de Lambada"
|
||||
className="rounded-2xl shadow-elevated w-full object-cover aspect-square"
|
||||
/>
|
||||
</motion.div>
|
||||
const AboutSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const highlightKeys = ["workshops", "socialDancing", "liveShows", "djSets"] as const;
|
||||
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold mb-6 text-gradient">
|
||||
{ABOUT_EVENT.title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed whitespace-pre-line">
|
||||
{ABOUT_EVENT.description}
|
||||
</p>
|
||||
<p className="text-foreground/90 mb-8 leading-relaxed italic border-l-4 border-primary pl-4">
|
||||
{ABOUT_EVENT.lambadaInfo}
|
||||
</p>
|
||||
return (
|
||||
<section id="about" className="section-padding bg-background relative z-10 -mt-[50px] pt-[100px]">
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Imagen */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<img
|
||||
src={aboutImg}
|
||||
alt={t("about.title")}
|
||||
className="rounded-2xl shadow-elevated w-full object-cover aspect-square"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Highlights */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{ABOUT_EVENT.highlights.map((item, i) => {
|
||||
const Icon = iconMap[i % iconMap.length];
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 bg-card rounded-lg p-3">
|
||||
<div className="bg-gradient-tropical rounded-full p-2 shrink-0">
|
||||
<Icon className="w-4 h-4 text-primary-foreground" />
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal mb-6 text-gradient">
|
||||
{t("about.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed whitespace-pre-line">
|
||||
{t("about.description")}
|
||||
</p>
|
||||
<p className="text-foreground/90 mb-8 leading-relaxed italic border-l-4 border-primary pl-4">
|
||||
{t("about.lambadaInfo")}
|
||||
</p>
|
||||
|
||||
{/* Highlights */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{highlightKeys.map((key, i) => {
|
||||
const Icon = iconMap[i % iconMap.length];
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 bg-card rounded-lg p-3">
|
||||
<div className="bg-gradient-tropical rounded-full p-2 shrink-0">
|
||||
<Icon className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{t(`about.highlights.${key}`)}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{item}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutSection;
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WEBHOOK_URL } from "@/data/event-data";
|
||||
import { WEBHOOK_URL, WEBHOOK_SECRET } from "@/data/event-data";
|
||||
import { CheckCircle, Loader2, AlertCircle, Search } from "lucide-react";
|
||||
import { EVENT_INFO } from "@/data/event-data";
|
||||
import { getTimeLeft } from "@/components/HeroSection";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* FORMULARIO DE RESERVA
|
||||
@@ -17,25 +20,25 @@ import { CheckCircle, Loader2, AlertCircle, Search } from "lucide-react";
|
||||
|
||||
// Define pass types with prices
|
||||
const PASS_TYPES = [
|
||||
{ id: "full", name: "Full Pass", price: 150 },
|
||||
{ id: "party", name: "Party Pass", price: 80 },
|
||||
{ id: "single", name: "Single Day Pass", price: 40 },
|
||||
{ id: "full", price: 150 },
|
||||
{ id: "party", price: 80 },
|
||||
{ id: "single", price: 40 },
|
||||
];
|
||||
|
||||
const bookingSchema = z.object({
|
||||
requestId: z.string().optional(),
|
||||
name: z.string().trim().min(2, "El nombre debe tener al menos 2 caracteres").max(100),
|
||||
surname: z.string().trim().min(2, "El apellido debe tener al menos 2 caracteres").max(100),
|
||||
email: z.string().trim().email("Email no válido").max(255),
|
||||
passType: z.string().min(1, "Selecciona un tipo de pass"),
|
||||
amount: z.string().min(1, "Selecciona la cantidad"),
|
||||
price: z.number().optional(),
|
||||
country: z.string().trim().min(2, "Indica tu país").max(100),
|
||||
});
|
||||
|
||||
type BookingData = z.infer<typeof bookingSchema>;
|
||||
type BookingData = {
|
||||
requestId?: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
passType: string;
|
||||
amount: string;
|
||||
price: number;
|
||||
country: string;
|
||||
};
|
||||
|
||||
const BookingSection = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [form, setForm] = useState<BookingData>({
|
||||
requestId: "", name: "", surname: "", email: "", passType: "", amount: "1", price: 0, country: "",
|
||||
});
|
||||
@@ -46,6 +49,31 @@ const BookingSection = () => {
|
||||
const countryDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const countryInputRef = useRef<HTMLInputElement>(null);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
const bookingSchema = z.object({
|
||||
requestId: z.string().optional(),
|
||||
name: z.string().trim().min(2, i18n.t("booking.validation.nameMin")).max(100),
|
||||
surname: z.string().trim().min(2, i18n.t("booking.validation.surnameMin")).max(100),
|
||||
email: z.string().trim().email(i18n.t("booking.validation.emailInvalid")).max(255),
|
||||
passType: z.string().min(1, i18n.t("booking.validation.passTypeRequired")),
|
||||
amount: z.string().min(1, i18n.t("booking.validation.amountRequired")),
|
||||
price: z.number().optional(),
|
||||
country: z.string().trim().min(2, i18n.t("booking.validation.countryMin")).max(100),
|
||||
});
|
||||
|
||||
const updatePassTypeOrAmount = (field: "passType" | "amount", value: string) => {
|
||||
setForm((prev) => {
|
||||
const updatedForm = { ...prev, [field]: value } as BookingData;
|
||||
const selectedPass = PASS_TYPES.find((pass) => pass.id === (field === "passType" ? value : updatedForm.passType));
|
||||
const amountValue = parseInt(updatedForm.amount);
|
||||
const newPrice = selectedPass ? selectedPass.price * amountValue : updatedForm.price;
|
||||
|
||||
return { ...updatedForm, price: newPrice };
|
||||
});
|
||||
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
};
|
||||
|
||||
// Generate unique requestId on component mount
|
||||
useEffect(() => {
|
||||
const uniqueId = `REQ-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
@@ -121,7 +149,7 @@ const BookingSection = () => {
|
||||
|
||||
const result = bookingSchema.safeParse(form);
|
||||
if (!result.success) {
|
||||
const fieldErrors: typeof errors = {};
|
||||
const fieldErrors: Partial<Record<keyof BookingData, string>> = {};
|
||||
result.error.errors.forEach((err) => {
|
||||
const field = err.path[0] as keyof BookingData;
|
||||
fieldErrors[field] = err.message;
|
||||
@@ -135,6 +163,10 @@ const BookingSection = () => {
|
||||
console.log("[Booking] Sending to:", WEBHOOK_URL);
|
||||
const res = await fetch(WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Secret": WEBHOOK_SECRET,
|
||||
},
|
||||
body: JSON.stringify(result.data),
|
||||
});
|
||||
console.log("[Booking] Response status:", res.status);
|
||||
@@ -147,20 +179,60 @@ const BookingSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
|
||||
const isEventOngoing = timeLeft.days === 0 && timeLeft.hours === 0 && timeLeft.minutes === 0 && timeLeft.seconds === 0;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft(getTimeLeft(EVENT_INFO.date));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<section id="booking" ref={sectionRef} className="section-padding bg-background scroll-mt-24">
|
||||
<section
|
||||
id="booking"
|
||||
ref={sectionRef}
|
||||
className="section-padding bg-background scroll-mt-24 relative z-10 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 120px 0 0" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-lg mx-auto text-center bg-card rounded-2xl p-10 shadow-elevated"
|
||||
>
|
||||
<CheckCircle className="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<h3 className="font-display text-2xl font-bold text-foreground mb-2">
|
||||
¡Reserva recibida!
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
|
||||
{t("booking.status.successTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Recibirás confirmación por email. ¡Nos vemos en la pista!
|
||||
{t("booking.status.success")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEventOngoing) {
|
||||
return (
|
||||
<section
|
||||
id="booking"
|
||||
ref={sectionRef}
|
||||
className="section-padding bg-background scroll-mt-24 relative z-10 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 120px 0 0" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-lg mx-auto text-center bg-card rounded-2xl p-10 shadow-elevated"
|
||||
>
|
||||
<AlertCircle className="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
|
||||
{t("booking.status.eventInProgressTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t("booking.status.eventInProgressDescription")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
@@ -168,26 +240,29 @@ const BookingSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="booking" ref={sectionRef} className="section-padding bg-background scroll-mt-24">
|
||||
<section
|
||||
id="booking"
|
||||
ref={sectionRef}
|
||||
className="section-padding bg-background scroll-mt-24 relative z-10 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 120px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
|
||||
Reserva tu pase
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("booking.subtitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Selecciona tu pase y cantidad. Sin pago online, paga en el evento.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("booking.description")}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-card rounded-2xl p-6 md:p-10 shadow-elevated space-y-5"
|
||||
>
|
||||
@@ -197,26 +272,30 @@ const BookingSection = () => {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Nombre *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t("booking.formFields.name")} *
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Tu nombre"
|
||||
placeholder={t("booking.formFields.name")}
|
||||
/>
|
||||
{errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Apellido */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Apellido *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t("booking.formFields.surname")} *
|
||||
</label>
|
||||
<input
|
||||
name="surname"
|
||||
value={form.surname}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Tu apellido"
|
||||
placeholder={t("booking.formFields.surname")}
|
||||
/>
|
||||
{errors.surname && <p className="text-destructive text-xs mt-1">{errors.surname}</p>}
|
||||
</div>
|
||||
@@ -224,21 +303,25 @@ const BookingSection = () => {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Email *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t("booking.formFields.email")} *
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="tu@email.com"
|
||||
placeholder={t("booking.formFields.email")}
|
||||
/>
|
||||
{errors.email && <p className="text-destructive text-xs mt-1">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* País - Searchable Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">País *</label>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{t("booking.formFields.country")} *
|
||||
</label>
|
||||
<div className="relative" ref={countryInputRef}>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -249,7 +332,7 @@ const BookingSection = () => {
|
||||
setShowCountryDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCountryDropdown(true)}
|
||||
placeholder="Buscar país..."
|
||||
placeholder={t("booking.formFields.countryPlaceholder")}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring pr-10"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
@@ -276,7 +359,7 @@ const BookingSection = () => {
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
No se encontraron países
|
||||
{t("booking.formFields.countryNoResults")}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -288,7 +371,9 @@ const BookingSection = () => {
|
||||
|
||||
{/* Tipo de Pass - Cards */}
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-foreground mb-3">Tipo de Pass *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-3">
|
||||
{t("booking.formFields.passType")} *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{PASS_TYPES.map((pass) => (
|
||||
<motion.div
|
||||
@@ -300,10 +385,16 @@ const BookingSection = () => {
|
||||
? "border-primary bg-primary/5 shadow-lg"
|
||||
: "border-border hover:border-primary/50 hover:shadow-md"
|
||||
}`}
|
||||
onClick={() => handleChange({ target: { name: "passType", value: pass.id } } as any)}
|
||||
onClick={() => updatePassTypeOrAmount("passType", pass.id)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg text-foreground mb-1">{pass.name}</h3>
|
||||
<h3 className="font-semibold text-lg text-foreground mb-1">
|
||||
{pass.id === "full"
|
||||
? t("booking.fullPass")
|
||||
: pass.id === "party"
|
||||
? t("booking.partyPass")
|
||||
: t("booking.singleDayPass")}
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-primary">{pass.price}€</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -314,7 +405,9 @@ const BookingSection = () => {
|
||||
|
||||
{/* Cantidad - Add/Subtract Buttons */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-foreground">Cantidad *</label>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{t("booking.quantity")} *
|
||||
</label>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<motion.button
|
||||
type="button"
|
||||
@@ -324,7 +417,7 @@ const BookingSection = () => {
|
||||
if (!form.passType) return;
|
||||
const currentAmount = parseInt(form.amount);
|
||||
if (currentAmount > 1) {
|
||||
handleChange({ target: { name: "amount", value: String(currentAmount - 1) } } as any);
|
||||
updatePassTypeOrAmount("amount", String(currentAmount - 1));
|
||||
}
|
||||
}}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
|
||||
@@ -353,7 +446,7 @@ const BookingSection = () => {
|
||||
if (!form.passType) return;
|
||||
const currentAmount = parseInt(form.amount);
|
||||
if (currentAmount < 10) {
|
||||
handleChange({ target: { name: "amount", value: String(currentAmount + 1) } } as any);
|
||||
updatePassTypeOrAmount("amount", String(currentAmount + 1));
|
||||
}
|
||||
}}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
|
||||
@@ -377,9 +470,9 @@ const BookingSection = () => {
|
||||
className="bg-gradient-to-r from-primary/10 to-primary/5 rounded-xl p-6 border border-primary/20"
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-1">Precio Total</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t("booking.priceSummaryLabel")}</p>
|
||||
<p className="text-3xl font-bold text-primary">{form.price.toFixed(2)}€</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">Sin pago online • Paga en el evento</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("booking.priceSummaryNote")}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -387,7 +480,7 @@ const BookingSection = () => {
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 text-destructive text-sm bg-destructive/10 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Error al enviar. Inténtalo de nuevo.
|
||||
{t("booking.status.error")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,9 +492,11 @@ const BookingSection = () => {
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</>
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> {t("booking.status.loading")}
|
||||
</>
|
||||
) : (
|
||||
"Reservar Pass"
|
||||
t("booking.buyButton")
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
|
||||
@@ -2,10 +2,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SECTIONS } from "@/data/event-data";
|
||||
|
||||
/** Botón flotante de reserva + scroll to top */
|
||||
const FloatingButton = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > 400);
|
||||
@@ -28,7 +31,7 @@ const FloatingButton = () => {
|
||||
className="animate-pulse-glow rounded-full px-6 shadow-elevated"
|
||||
asChild
|
||||
>
|
||||
<a href="#booking" className="inline-flex items-center gap-2">
|
||||
<a href={SECTIONS.booking ? "#booking" : "#mixed-booking"} className="inline-flex items-center gap-2">
|
||||
{/* Inline SVG: dancing couple icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -42,15 +45,15 @@ const FloatingButton = () => {
|
||||
<path d="M4.25 20a.75.75 0 0 1-.743-.653l-.23-1.61a3.75 3.75 0 0 1 1.64-3.67l2.41-1.6a1.5 1.5 0 0 0 .67-1.1l.16-1.3a2.25 2.25 0 0 1 2.22-1.97h.2a2.25 2.25 0 0 1 1.98 1.19l.54 1.03 1.66.83a3 3 0 0 1 1.33 1.22l1.76 2.89c.23.38.09.87-.29 1.1a.8.8 0 0 1-1.08-.28l-1.47-2.42a1.5 1.5 0 0 0-.67-.61l-1.63-.81-.25 1.77a2.25 2.25 0 0 1-1.01 1.55l-2.98 2a2.25 2.25 0 0 0-.98 1.64l-.06.92A.75.75 0 0 1 6 20H4.25Z" />
|
||||
<path d="M14.75 6.5a1 1 0 0 1 1.5-.86l2 .99a2.5 2.5 0 0 1 1.12 3.26l-.37.78a.75.75 0 1 1-1.36-.64l.37-.78a1 1 0 0 0-.45-1.3l-1.99-.99a1 1 0 0 1-.32-1.46Z" />
|
||||
</svg>
|
||||
Reservar
|
||||
{t("nav.booking")}
|
||||
</a>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
className="self-center bg-foreground/20 backdrop-blur-sm p-2 rounded-full hover:bg-foreground/30 transition-colors"
|
||||
aria-label="Volver arriba"
|
||||
className="self-center bg-gray-500 text-white p-2 rounded-full hover:bg-gray-600 transition-colors shadow"
|
||||
aria-label={t("common.toTop")}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4 text-foreground" />
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,64 +1,99 @@
|
||||
import { ABOUT_ORG, FOOTER } from "@/data/event-data";
|
||||
import { ABOUT_ORG } from "@/data/event-data";
|
||||
import { Instagram, Facebook, Youtube, Mail } from "lucide-react";
|
||||
import hacecalor from "@/assets/hacecalor.png";
|
||||
import activat from "@/assets/activat.png";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const FooterSection = () => (
|
||||
<footer className="bg-foreground text-primary-foreground">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<h3 className="font-display text-2xl font-bold mb-3">ZoukLambadaBCN</h3>
|
||||
<p className="text-primary-foreground/70 text-sm">
|
||||
<img src={hacecalor} alt="Hacecalor" className="inline-block w-auto h-12 mr-1" />
|
||||
<img src={activat} alt="Activat" className="inline-block w-auto h-12 mr-1" />
|
||||
</p>
|
||||
</div>
|
||||
const FooterSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const year = new Date().getFullYear();
|
||||
const email = t("footer.email");
|
||||
const copyright = t("footer.copyright", { year });
|
||||
|
||||
{/* Contacto */}
|
||||
<div>
|
||||
<h4 className="font-display text-lg font-semibold mb-3">Contacto</h4>
|
||||
<a
|
||||
href={`mailto:${FOOTER.email}`}
|
||||
className="flex items-center gap-2 text-sm text-primary-foreground/70 hover:text-primary transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{FOOTER.email}
|
||||
</a>
|
||||
</div>
|
||||
return (
|
||||
<motion.footer
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="bg-foreground text-primary-foreground relative z-30 -mt-[40px] pt-[80px]"
|
||||
style={{ clipPath: "polygon(50% 0, 100% 40px, 100% 100%, 0 100%, 0 40px)" }}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<h3 className="font-display text-3xl md:text-4xl lg:text-5xl pt-3 pb-6 break-words leading-[1.6] font-bold mb-2">ZoukLambadaBCN</h3>
|
||||
<p className="text-primary-foreground/70 text-sm">
|
||||
<img src={hacecalor} alt="Hacecalor" className="inline-block w-auto h-12 mr-1" />
|
||||
<img src={activat} alt="Activat" className="inline-block w-auto h-12 mr-1" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Redes */}
|
||||
<div>
|
||||
<h4 className="font-display text-lg font-semibold mb-3">Síguenos</h4>
|
||||
<div className="flex gap-3">
|
||||
{ABOUT_ORG.socials.instagram && (
|
||||
<a href={ABOUT_ORG.socials.instagram} target="_blank" rel="noopener noreferrer" aria-label="Instagram"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
|
||||
<Instagram className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.facebook && (
|
||||
<a href={ABOUT_ORG.socials.facebook} target="_blank" rel="noopener noreferrer" aria-label="Facebook"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.youtube && (
|
||||
<a href={ABOUT_ORG.socials.youtube} target="_blank" rel="noopener noreferrer" aria-label="YouTube"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
|
||||
<Youtube className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{/* Contacto */}
|
||||
<div>
|
||||
<h4 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">
|
||||
{t("footer.contact")}
|
||||
</h4>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="flex items-center gap-2 text-sm text-primary-foreground/70 hover:text-primary transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Redes */}
|
||||
<div>
|
||||
<h4 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">
|
||||
{t("footer.followUs")}
|
||||
</h4>
|
||||
<div className="flex gap-3">
|
||||
{ABOUT_ORG.socials.instagram && (
|
||||
<a
|
||||
href={ABOUT_ORG.socials.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors"
|
||||
>
|
||||
<Instagram className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.facebook && (
|
||||
<a
|
||||
href={ABOUT_ORG.socials.facebook}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Facebook"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors"
|
||||
>
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.youtube && (
|
||||
<a
|
||||
href={ABOUT_ORG.socials.youtube}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="YouTube"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors"
|
||||
>
|
||||
<Youtube className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-primary-foreground/10 pt-6 text-center">
|
||||
<p className="text-sm text-primary-foreground/50">{FOOTER.copyright}</p>
|
||||
<div className="border-t border-primary-foreground/10 pt-6 text-center">
|
||||
<p className="text-sm text-primary-foreground/50">{copyright}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
</motion.footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSection;
|
||||
|
||||
@@ -1,49 +1,58 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { GALLERY_IMAGES } from "@/data/event-data";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const GallerySection = () => (
|
||||
<section id="gallery" className="section-padding bg-card">
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
|
||||
Galería
|
||||
</h2>
|
||||
</motion.div>
|
||||
const GallerySection = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-5xl mx-auto">
|
||||
{GALLERY_IMAGES.map((img, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="aspect-square rounded-xl overflow-hidden bg-muted flex items-center justify-center"
|
||||
>
|
||||
{img.src ? (
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground/40">
|
||||
<ImageIcon className="w-10 h-10 mx-auto mb-2" />
|
||||
<p className="text-xs">{img.alt}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
return (
|
||||
<section
|
||||
id="gallery"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 100px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("gallery.title")}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-5xl mx-auto">
|
||||
{GALLERY_IMAGES.map((img, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="aspect-square rounded-xl overflow-hidden bg-muted flex items-center justify-center"
|
||||
>
|
||||
{img.src ? (
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground/40">
|
||||
<ImageIcon className="w-10 h-10 mx-auto mb-2" />
|
||||
<p className="text-xs">{img.alt}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EVENT_INFO } from "@/data/event-data";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EVENT_INFO, SECTIONS } from "@/data/event-data";
|
||||
import heroBg from "@/assets/hero-bg.jpg";
|
||||
|
||||
/** Calcula diferencia entre ahora y la fecha del evento */
|
||||
const getTimeLeft = (target: string) => {
|
||||
export const getTimeLeft = (target: string) => {
|
||||
const diff = new Date(target).getTime() - Date.now();
|
||||
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
return {
|
||||
@@ -17,6 +18,7 @@ const getTimeLeft = (target: string) => {
|
||||
};
|
||||
|
||||
const HeroSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,14 +29,17 @@ const HeroSection = () => {
|
||||
}, []);
|
||||
|
||||
const countdownItems = [
|
||||
{ value: timeLeft.days, label: "Días" },
|
||||
{ value: timeLeft.hours, label: "Horas" },
|
||||
{ value: timeLeft.minutes, label: "Min" },
|
||||
{ value: timeLeft.seconds, label: "Seg" },
|
||||
{ value: timeLeft.days, label: t('hero.days') },
|
||||
{ value: timeLeft.hours, label: t('hero.hours') },
|
||||
{ value: timeLeft.minutes, label: t('hero.minutes') },
|
||||
{ value: timeLeft.seconds, label: t('hero.seconds') },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
<section
|
||||
className="relative min-h-[calc(100vh+50px)] flex items-center justify-center overflow-hidden z-20 pb-[50px]"
|
||||
style={{ clipPath: "polygon(0 0, 100% 0, 100% calc(100% - 50px), 50% 100%, 0 calc(100% - 50px))" }}
|
||||
>
|
||||
{/* Background image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
@@ -50,16 +55,16 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-primary font-body text-sm uppercase tracking-[0.3em] mb-4"
|
||||
>
|
||||
{EVENT_INFO.dateDisplay} · {EVENT_INFO.city}
|
||||
{t('hero.dates')} · {t('hero.location')}
|
||||
</motion.p>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="font-display text-5xl md:text-7xl lg:text-8xl font-bold text-primary-foreground mb-4 leading-tight"
|
||||
className="font-hero text-4xl md:text-7xl lg:text-8xl font-bold text-primary-foreground mb-4 leading-tight"
|
||||
>
|
||||
{EVENT_INFO.name}
|
||||
{t('hero.title')}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
@@ -68,7 +73,7 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.6 }}
|
||||
className="text-primary-foreground/80 font-body text-lg md:text-xl mb-10"
|
||||
>
|
||||
{EVENT_INFO.subtitle}
|
||||
{t('hero.subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* Countdown */}
|
||||
@@ -78,18 +83,31 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.8 }}
|
||||
className="flex justify-center gap-4 md:gap-8 mb-10"
|
||||
>
|
||||
{countdownItems.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="bg-primary-foreground/10 backdrop-blur-sm border border-primary-foreground/20 rounded-lg px-4 py-3 md:px-6 md:py-4 min-w-[60px] md:min-w-[80px]">
|
||||
<span className="text-2xl md:text-4xl font-display font-bold text-primary-foreground">
|
||||
{String(item.value).padStart(2, "0")}
|
||||
{timeLeft.days === 0 && timeLeft.hours === 0 && timeLeft.minutes === 0 && timeLeft.seconds === 0 ? (
|
||||
<div className="text-center">
|
||||
<div className="bg-primary-foreground/10 backdrop-blur-sm border border-primary-foreground/20 rounded-lg px-6 py-4 min-w-[180px]">
|
||||
<span className="text-2xl md:text-4xl font-hero font-bold text-primary-foreground">
|
||||
{t('hero.eventInProgress')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-primary-foreground/60 text-xs mt-2 uppercase tracking-wider">
|
||||
{item.label}
|
||||
{t('hero.dontMiss')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
countdownItems.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="bg-primary-foreground/10 backdrop-blur-sm border border-primary-foreground/20 rounded-lg px-4 py-3 md:px-6 md:py-4 min-w-[60px] md:min-w-[80px]">
|
||||
<span className="text-2xl md:text-4xl font-hero font-bold text-primary-foreground">
|
||||
{String(item.value).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-primary-foreground/60 text-xs mt-2 uppercase tracking-wider">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -98,7 +116,7 @@ const HeroSection = () => {
|
||||
transition={{ delay: 1 }}
|
||||
>
|
||||
<Button variant="hero" size="lg" className="text-lg px-10 py-6" asChild>
|
||||
<a href="#booking">Reservar tu pase</a>
|
||||
<a href={SECTIONS.booking ? "#booking" : "#mixed-booking"}>{t('hero.bookYourPass')}</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -2,60 +2,79 @@ import { motion } from "framer-motion";
|
||||
import { HOTEL_ROOMS } from "@/data/event-data";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BedDouble, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const HotelSection = () => (
|
||||
<section id="hotel" className="section-padding bg-card">
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
|
||||
Alojamiento
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Habitaciones recomendadas cerca del venue.
|
||||
</p>
|
||||
</motion.div>
|
||||
const HotelSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const roomTypeKeyById: Record<string, "individual" | "double" | "suite"> = {
|
||||
"1": "individual",
|
||||
"2": "double",
|
||||
"3": "suite",
|
||||
};
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{HOTEL_ROOMS.map((room, i) => (
|
||||
<motion.div
|
||||
key={room.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-background rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow"
|
||||
>
|
||||
{/* Imagen */}
|
||||
<div className="aspect-video bg-muted flex items-center justify-center overflow-hidden">
|
||||
{room.image ? (
|
||||
<img src={room.image} alt={room.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<BedDouble className="w-12 h-12 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<section
|
||||
id="hotel"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "50% 50% 0 0 / 60px 60px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("hotel.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">{t("hotel.subtitle")}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="p-5">
|
||||
<h3 className="font-display text-lg font-bold text-foreground mb-1">
|
||||
{room.name}
|
||||
</h3>
|
||||
<p className="text-primary font-bold text-xl mb-2">{room.price}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">{room.description}</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href={room.link} target="_blank" rel="noopener noreferrer">
|
||||
Reservar en el hotel <ExternalLink className="w-4 h-4 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{HOTEL_ROOMS.map((room, i) => {
|
||||
const typeKey = roomTypeKeyById[room.id];
|
||||
const translatedName = typeKey ? t(`hotel.${typeKey}`) : room.name;
|
||||
const translatedPrice = room.price?.includes("[") ? t("hotel.pricePerNight") : room.price;
|
||||
const translatedDescription = room.description?.includes("[") ? t("hotel.description") : room.description;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={room.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-background rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow"
|
||||
>
|
||||
{/* Imagen */}
|
||||
<div className="aspect-video bg-muted flex items-center justify-center overflow-hidden">
|
||||
{room.image ? (
|
||||
<img src={room.image} alt={translatedName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<BedDouble className="w-12 h-12 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<h3 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-1">
|
||||
{translatedName}
|
||||
</h3>
|
||||
<p className="text-primary font-bold text-xl mb-2">{translatedPrice}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">{translatedDescription}</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href={room.link} target="_blank" rel="noopener noreferrer">
|
||||
{t("hotel.bookButton")} <ExternalLink className="w-4 h-4 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotelSection;
|
||||
|
||||
213
src/components/MixedBookingSection.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { MIXED_BOOKING_PACKAGES, ROOM_TYPES, getFullPassPrice } from "@/data/event-data";
|
||||
import type { RoomType } from "@/data/event-data";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Star, Circle, CheckCircle2 } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const FEATURED_PASS = "full";
|
||||
const NON_HOTEL_FEE = 50;
|
||||
const PARTY_PRICE = 25;
|
||||
|
||||
const MixedBookingSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const fullPassPricing = getFullPassPrice();
|
||||
const [selectedRooms, setSelectedRooms] = useState<Record<string, RoomType>>({
|
||||
full: "individual",
|
||||
party: "individual",
|
||||
});
|
||||
const [wantsHotel, setWantsHotel] = useState<Record<string, boolean | null>>({
|
||||
full: null,
|
||||
party: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
id="mixed-booking"
|
||||
className="section-padding bg-background scroll-mt-24 relative z-10 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 120px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("mixedBooking.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">{t("mixedBooking.subtitle")}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6 lg:gap-8 max-w-4xl mx-auto items-stretch">
|
||||
{MIXED_BOOKING_PACKAGES.map((pkg, i) => {
|
||||
const selectedRoom = selectedRooms[pkg.id] || "individual";
|
||||
const hasHotel = wantsHotel[pkg.id] === true;
|
||||
// Use dynamic pricing for Full Pass, fixed price for Party Pass
|
||||
// Only Full Pass has the +50€ non-hotel fee
|
||||
let basePrice = pkg.id === "full" ? fullPassPricing.price : PARTY_PRICE;
|
||||
let roomPrice = 0;
|
||||
if (hasHotel) {
|
||||
roomPrice = pkg.roomPrices[selectedRoom as keyof typeof pkg.roomPrices];
|
||||
}
|
||||
|
||||
const passPrice = pkg.id === "full"
|
||||
? (hasHotel ? basePrice : basePrice + NON_HOTEL_FEE)
|
||||
: basePrice;
|
||||
const price = passPrice + roomPrice;
|
||||
|
||||
const isFeatured = pkg.id === FEATURED_PASS;
|
||||
const isLastPrice = pkg.id === "full" && fullPassPricing.isLastPrice;
|
||||
const showNonHotelNote = pkg.id === "full" && !hasHotel && basePrice > 0;
|
||||
const features = t(`mixedBooking.${pkg.id}Features`).split("|").map((f: string) => f.trim());
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={pkg.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className={`relative flex h-full min-h-[520px] flex-col rounded-2xl overflow-hidden transition-shadow duration-300 ${
|
||||
isFeatured
|
||||
? "shadow-elevated border-2 border-primary/30"
|
||||
: "shadow-card hover:shadow-elevated border border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 items-end">
|
||||
{isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 bg-gradient-tropical text-primary-foreground text-xs font-bold px-3 py-1 rounded-full shadow-md">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{t("mixedBooking.popular")}
|
||||
</span>
|
||||
)}
|
||||
{isLastPrice && (
|
||||
<span className="inline-flex items-center gap-1 bg-red-600 text-white text-xs font-bold px-3 py-1 rounded-full shadow-md">
|
||||
{t("mixedBooking.lastPrice")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`px-6 pt-8 pb-4 text-center min-h-[160px] flex flex-col justify-between ${
|
||||
isFeatured ? "bg-gradient-tropical" : "bg-secondary/10"
|
||||
}`}>
|
||||
<div>
|
||||
<h3 className={`font-display text-2xl md:text-3xl font-bold mb-1 ${
|
||||
isFeatured ? "text-primary-foreground" : "text-foreground"
|
||||
}`}>
|
||||
{t(`mixedBooking.passTypes.${pkg.id}`)}
|
||||
</h3>
|
||||
<p className={`text-sm ${
|
||||
isFeatured ? "text-primary-foreground/80" : "text-muted-foreground"
|
||||
}`}>
|
||||
{t(`mixedBooking.${pkg.id}Description`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pt-6 pb-4 text-center bg-card">
|
||||
<p className="text-4xl md:text-5xl font-bold text-primary">
|
||||
{price > 0 ? `${price}€` : t("mixedBooking.priceTBD")}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground mt-2">
|
||||
{t(`mixedBooking.passTypes.${pkg.id}`)}: {passPrice}€ {pkg.id === "full" ? t("mixedBooking.perPerson") : t("mixedBooking.perParty")}
|
||||
</p>
|
||||
{showNonHotelNote && (
|
||||
<p className="text-xs text-red-600 font-medium mt-2">
|
||||
{t("mixedBooking.nonHotelNote")}
|
||||
</p>
|
||||
)}
|
||||
{hasHotel && (
|
||||
<p className="text-sm font-medium text-foreground mt-2">
|
||||
{t(`mixedBooking.roomTypes.${selectedRoom}`)}: {roomPrice}€
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pt-4 pb-2 bg-card">
|
||||
<Label className="block text-sm font-medium text-foreground mb-3">
|
||||
{t("mixedBooking.hotelQuestion")}
|
||||
</Label>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => setWantsHotel((prev) => ({ ...prev, [pkg.id]: true }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all duration-200 ${
|
||||
hasHotel
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-input bg-background text-muted-foreground hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={`w-5 h-5 ${hasHotel ? "text-primary" : "text-muted-foreground"}`} />
|
||||
{t("mixedBooking.hotelYes")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setWantsHotel((prev) => ({ ...prev, [pkg.id]: false }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all duration-200 ${
|
||||
!hasHotel
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-input bg-background text-muted-foreground hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
<Circle className={`w-5 h-5 ${!hasHotel ? "text-primary" : "text-muted-foreground"}`} />
|
||||
{t("mixedBooking.hotelNo")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasHotel && (
|
||||
<>
|
||||
<Label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t("mixedBooking.selectRoom")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedRoom}
|
||||
onValueChange={(value) =>
|
||||
setSelectedRooms((prev) => ({
|
||||
...prev,
|
||||
[pkg.id]: value as RoomType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={`w-full rounded-xl border px-4 py-3 text-sm text-foreground shadow-sm transition-colors duration-200 ${
|
||||
isFeatured
|
||||
? "border-primary/30 bg-primary/5 hover:border-primary/50"
|
||||
: "border-input bg-background hover:border-primary/30"
|
||||
}`}>
|
||||
<SelectValue placeholder={t("mixedBooking.selectRoom")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROOM_TYPES.map((room) => (
|
||||
<SelectItem key={room.id} value={room.id}>
|
||||
{t(`mixedBooking.roomTypes.${room.id}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pt-4 pb-6 flex-1 flex flex-col justify-between bg-card">
|
||||
<ul className="space-y-2.5 mb-6">
|
||||
{features.map((feature: string, idx: number) => (
|
||||
<li key={idx} className="flex items-start gap-2.5 text-sm text-foreground/80">
|
||||
<Check className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
|
||||
isFeatured ? "text-primary" : "text-secondary"
|
||||
}`} />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MixedBookingSection;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, Globe } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NAV_LINKS } from "@/data/event-data";
|
||||
import Logo from "@/assets/logo.png";
|
||||
|
||||
@@ -11,6 +12,12 @@ import Logo from "@/assets/logo.png";
|
||||
const Navbar = () => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === 'es' ? 'en' : 'es';
|
||||
i18n.changeLanguage(newLang);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 50);
|
||||
@@ -18,18 +25,27 @@ const Navbar = () => {
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
return () => { document.body.style.overflow = "unset"; };
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? "bg-background/95 backdrop-blur-md shadow-card"
|
||||
: "bg-white/30"
|
||||
? "bg-background shadow-card"
|
||||
: "bg-background-white"
|
||||
}`}
|
||||
>
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-3">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-2 md:py-3 relative z-50">
|
||||
{/* Logo */}
|
||||
<a href="#" className="font-display text-xl font-bold text-gradient">
|
||||
<img src={Logo} alt="ZLB Logo" className="h-8 w-auto" />
|
||||
<a href="#" className="font-display text-xl font-bold text-gradient" onClick={() => setMenuOpen(false)}>
|
||||
<img src={Logo} alt="ZLB Logo" className="h-10 md:h-12 w-auto" />
|
||||
</a>
|
||||
|
||||
{/* Desktop links */}
|
||||
@@ -38,42 +54,75 @@ const Navbar = () => {
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-foreground/80 hover:text-primary transition-colors"
|
||||
className="text-sm font-medium text-black/80 hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
{t(`nav.${link.href.substring(1)}`)}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Language Switcher */}
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<Globe size={18} />
|
||||
<span className="text-sm font-medium">
|
||||
{i18n.language === 'es' ? 'EN' : 'ES'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
className="md:hidden text-foreground"
|
||||
className="md:hidden text-foreground hover:text-primary transition-colors focus:outline-none"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
{menuOpen ? <X size={28} /> : <Menu size={28} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="md:hidden bg-background/98 backdrop-blur-md border-t border-border px-4 pb-4"
|
||||
>
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block py-3 text-sm font-medium text-foreground/80 hover:text-primary transition-colors border-b border-border/50"
|
||||
{/* Mobile menu (Full Screen) */}
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: "-100%" }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="md:hidden fixed inset-0 z-40 bg-background flex flex-col items-center justify-center gap-8"
|
||||
>
|
||||
{NAV_LINKS.map((link, i) => (
|
||||
<motion.a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 30 }}
|
||||
transition={{ delay: 0.1 * i }}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="text-2xl md:text-4xl lg:text-5xl py-3 break-words leading-[1.6] font-display font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{t(`nav.${link.href.substring(1)}`)}
|
||||
</motion.a>
|
||||
))}
|
||||
|
||||
{/* Language Switcher */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 30 }}
|
||||
transition={{ delay: 0.1 * NAV_LINKS.length }}
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center gap-3 text-2xl md:text-4xl lg:text-5xl py-3 font-display font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
<Globe className="w-6 h-6 md:w-10 md:h-10" />
|
||||
{i18n.language === 'es' ? 'EN' : 'ES'}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,25 +2,33 @@ import { motion } from "framer-motion";
|
||||
import { ABOUT_ORG } from "@/data/event-data";
|
||||
import communityImg from "@/assets/community.jpg";
|
||||
import { Instagram, Facebook, Youtube } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const OrgSection = () => (
|
||||
<section className="section-padding bg-card py-20 md:py-28">
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold mb-6 text-gradient">
|
||||
{ABOUT_ORG.title}
|
||||
</h2>
|
||||
<div className="space-y-4 text-muted-foreground leading-relaxed">
|
||||
<p className="whitespace-pre-line">{ABOUT_ORG.history}</p>
|
||||
<p className="whitespace-pre-line">{ABOUT_ORG.philosophy}</p>
|
||||
</div>
|
||||
const maskStyle = "radial-gradient(196.7px at 50% 264px, rgb(0, 0, 0) 99%, rgba(0, 0, 0, 0) 101%) calc(50% - 176px) 0px / 352px 100%, radial-gradient(196.7px at 50% -176px, rgba(0, 0, 0, 0) 99%, rgb(0, 0, 0) 101%) 50% 78px / 352px 100% repeat-x";
|
||||
|
||||
const OrgSection = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section
|
||||
className="section-padding bg-card pb-20 md:pb-28 pt-[80px] md:pt-[100px] -mt-[32px] relative z-20"
|
||||
style={{ WebkitMask: maskStyle, mask: maskStyle }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal mb-6 text-gradient">
|
||||
{t("about.orgTitle")}
|
||||
</h2>
|
||||
<div className="space-y-4 text-muted-foreground leading-relaxed">
|
||||
<p className="whitespace-pre-line">{t("about.orgDescription")}</p>
|
||||
</div>
|
||||
|
||||
{/* Redes sociales */}
|
||||
<div className="flex gap-4 mt-8">
|
||||
@@ -58,13 +66,13 @@ const OrgSection = () => (
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Imagen */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<img
|
||||
@@ -73,9 +81,10 @@ const OrgSection = () => (
|
||||
className="rounded-2xl shadow-elevated w-full h-auto object-contain bg-muted"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrgSection;
|
||||
|
||||
@@ -1,89 +1,113 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { EVENT_INFO, PRACTICAL_INFO } from "@/data/event-data";
|
||||
import { MapPin, Plane, Train } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PracticalSection = () => (
|
||||
<section id="info" className="section-padding bg-background">
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
|
||||
Información Práctica
|
||||
</h2>
|
||||
</motion.div>
|
||||
const PracticalSection = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Mapa */}
|
||||
return (
|
||||
<section
|
||||
id="info"
|
||||
className="section-padding bg-background relative z-10 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 100px 0 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<div className="rounded-2xl overflow-hidden shadow-card mb-4 aspect-video">
|
||||
<iframe
|
||||
src={EVENT_INFO.mapEmbedUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title="Ubicación del evento"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<MapPin className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal text-gradient mb-4">
|
||||
{t("info.title")}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Mapa */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
>
|
||||
<div className="rounded-2xl overflow-hidden shadow-card mb-4 aspect-video">
|
||||
<iframe
|
||||
src={EVENT_INFO.mapEmbedUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title={t("info.mapTitle")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<MapPin className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<p className="font-semibold">{EVENT_INFO.venue}</p>
|
||||
<p className="text-sm text-muted-foreground">{EVENT_INFO.venueAddress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Aeropuertos */}
|
||||
<div>
|
||||
<p className="font-semibold">{EVENT_INFO.venue}</p>
|
||||
<p className="text-sm text-muted-foreground">{EVENT_INFO.venueAddress}</p>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2 flex items-center gap-2">
|
||||
<Plane className="w-5 h-5 text-primary" /> {t("info.airports")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.airports.map((a) => (
|
||||
<div key={a.name} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.distance}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Aeropuertos */}
|
||||
<div>
|
||||
<h3 className="font-display text-xl font-bold text-foreground mb-4 flex items-center gap-2">
|
||||
<Plane className="w-5 h-5 text-primary" /> Aeropuertos Cercanos
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.airports.map((a) => (
|
||||
<div key={a.name} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.distance}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Cómo llegar */}
|
||||
<div>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2 flex items-center gap-2">
|
||||
<Train className="w-5 h-5 text-primary" /> {t("info.howToGet")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.howToGet.map((h) => {
|
||||
const methodLabel =
|
||||
h.method === "Metro" ? t("info.metro") : h.method === "Bus" ? t("info.bus") : h.method === "Taxi/Uber" ? t("info.taxi") : h.method;
|
||||
|
||||
{/* Cómo llegar */}
|
||||
<div>
|
||||
<h3 className="font-display text-xl font-bold text-foreground mb-4 flex items-center gap-2">
|
||||
<Train className="w-5 h-5 text-primary" /> Cómo Llegar
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.howToGet.map((h) => (
|
||||
<div key={h.method} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{h.method}</p>
|
||||
<p className="text-xs text-muted-foreground">{h.details}</p>
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<div key={h.method} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{methodLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">{h.details}</p>
|
||||
{h.link && (
|
||||
<a
|
||||
href={h.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 px-3 py-1 bg-primary text-primary-foreground text-xs rounded hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("info.viewOnMap")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PracticalSection;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { SCHEDULE } from "@/data/event-data";
|
||||
import { useState, useEffect } from "react";
|
||||
import { SCHEDULE, EVENT_INFO } from "@/data/event-data";
|
||||
import { Clock, Music, Coffee, Star } from "lucide-react";
|
||||
import { getTimeLeft } from "@/components/HeroSection";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const typeIcon: Record<string, typeof Clock> = {
|
||||
workshop: Clock,
|
||||
@@ -16,59 +19,120 @@ const typeColor: Record<string, string> = {
|
||||
show: "border-accent bg-accent/10 text-accent",
|
||||
};
|
||||
|
||||
const ScheduleSection = () => (
|
||||
<section id="schedule" className="section-padding bg-card">
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
|
||||
Programa
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Tres días de workshops, shows y social dance.
|
||||
</p>
|
||||
</motion.div>
|
||||
const ScheduleSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
|
||||
const isEventOngoing = timeLeft.days === 0 && timeLeft.hours === 0 && timeLeft.minutes === 0 && timeLeft.seconds === 0;
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{SCHEDULE.map((day, di) => (
|
||||
<motion.div
|
||||
key={day.day}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: di * 0.15 }}
|
||||
>
|
||||
<h3 className="font-display text-xl font-bold text-foreground mb-6 pb-3 border-b-2 border-primary">
|
||||
{day.day}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{day.events.map((event, ei) => {
|
||||
const Icon = typeIcon[event.type] || Clock;
|
||||
return (
|
||||
<div
|
||||
key={ei}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border-l-4 ${
|
||||
typeColor[event.type] || ""
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium opacity-70">{event.time}</p>
|
||||
<p className="text-sm font-semibold">{event.title}</p>
|
||||
const dayKeys = ["friday", "saturday", "sunday"] as const;
|
||||
|
||||
const formatEventTitle = (event: { type: string; title: string }, dayIndex: number) => {
|
||||
switch (event.type) {
|
||||
case "workshop": {
|
||||
const match = event.title.match(/Workshop\\s*(\\d+)/i);
|
||||
return match?.[1] ? `${t("schedule.workshop")} ${match[1]}` : t("schedule.workshop");
|
||||
}
|
||||
case "break":
|
||||
return t("schedule.break");
|
||||
case "show":
|
||||
return t("schedule.show");
|
||||
case "social":
|
||||
return dayIndex === 2 ? t("schedule.farewell") : t("schedule.social");
|
||||
default:
|
||||
return event.title;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft(getTimeLeft(EVENT_INFO.date));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!isEventOngoing) {
|
||||
return (
|
||||
<section
|
||||
id="schedule"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 120px 0 0 0" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-lg mx-auto text-center bg-card rounded-2xl p-10 shadow-elevated"
|
||||
>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
|
||||
{t("schedule.statusBriefTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t("schedule.statusBriefDescription")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id="schedule"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 120px 0 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("schedule.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("schedule.subtitle")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{SCHEDULE.map((day, di) => (
|
||||
<motion.div
|
||||
key={day.day}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: di * 0.15 }}
|
||||
>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-3 leading-[1.6] font-bold text-foreground mb-4 border-b-2 border-primary">
|
||||
{t(`schedule.${dayKeys[di]}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{day.events.map((event, ei) => {
|
||||
const Icon = typeIcon[event.type] || Clock;
|
||||
return (
|
||||
<div
|
||||
key={ei}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border-l-4 ${
|
||||
typeColor[event.type] || ""
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium opacity-70">{event.time}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatEventTitle(event as { type: string; title: string }, di)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleSection;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { STAFF } from "@/data/event-data";
|
||||
import { Instagram, User, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** Colores de badge por rol */
|
||||
const roleBadgeClass: Record<string, string> = {
|
||||
@@ -10,6 +12,15 @@ const roleBadgeClass: Record<string, string> = {
|
||||
};
|
||||
|
||||
const StaffSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<"all" | "instructors" | "djs">("all");
|
||||
|
||||
const filteredStaff = STAFF.filter((member) => {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "instructors") return member.role === "Instructor";
|
||||
if (filter === "djs") return member.role === "DJ";
|
||||
return true;
|
||||
});
|
||||
// simple ref + helpers for horizontal scroll
|
||||
const onPrev = () => {
|
||||
const scroller = document.getElementById("staff-scroller");
|
||||
@@ -27,20 +38,24 @@ const StaffSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="staff" className="section-padding bg-background">
|
||||
<section
|
||||
id="staff"
|
||||
className="section-padding bg-background relative z-10 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "50% 50% 0 0 / 80px 80px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="flex items-end justify-between gap-4 mb-6 md:mb-8"
|
||||
>
|
||||
<div className="text-center md:text-left w-full">
|
||||
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-2">
|
||||
Staff del Evento
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal text-gradient mb-2">
|
||||
{t("staff.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto md:mx-0">
|
||||
Conoce a los artistas e instructores que harán de este festival una experiencia inolvidable.
|
||||
{t("staff.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -63,33 +78,63 @@ const StaffSection = () => {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="flex flex-wrap gap-3 mb-8 justify-center md:justify-start"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ id: "all", labelKey: "staff.filters.all" },
|
||||
{ id: "instructors", labelKey: "staff.filters.instructors" },
|
||||
{ id: "djs", labelKey: "staff.filters.djs" },
|
||||
] as const
|
||||
).map(({ id, labelKey }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setFilter(id)}
|
||||
className={`px-6 py-2 rounded-full text-sm font-semibold transition-all duration-300 border ${
|
||||
filter === id
|
||||
? "bg-primary text-primary-foreground border-primary shadow-md"
|
||||
: "bg-card text-foreground border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Carousel scroller */}
|
||||
<div className="relative">
|
||||
{/* gradient edges */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent rounded-l-2xl" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent rounded-r-2xl" />
|
||||
<div className="hidden md:block pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent rounded-l-2xl z-20" />
|
||||
<div className="hidden md:block pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent rounded-r-2xl z-20" />
|
||||
|
||||
<div
|
||||
id="staff-scroller"
|
||||
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth pb-2 -mx-4 px-4 md:mx-0 md:px-0"
|
||||
className="flex gap-6 overflow-x-auto overflow-y-hidden snap-x snap-mandatory scroll-smooth pb-8 pt-6 -mx-4 px-4 md:mx-0 md:px-0 min-h-[400px]"
|
||||
>
|
||||
{STAFF.map((member, i) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
data-staff-card
|
||||
className="snap-start shrink-0 w-4/5 sm:w-1/2 lg:w-1/3 xl:w-1/4 bg-card rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow group"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredStaff.map((member, i) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.05 }}
|
||||
data-staff-card
|
||||
className="snap-start shrink-0 w-4/5 sm:w-1/2 lg:w-1/3 xl:w-1/4 bg-card rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow group"
|
||||
>
|
||||
{/* Foto */}
|
||||
<div className="bg-muted flex items-center justify-center overflow-hidden">
|
||||
<div className="bg-muted flex items-center justify-center overflow-hidden w-full aspect-[4/5]">
|
||||
{member.image ? (
|
||||
<img
|
||||
src={member.image}
|
||||
alt={member.name}
|
||||
className="w-full h-auto object-contain object-center group-hover:scale-105 transition-transform duration-500 bg-muted"
|
||||
className="w-full h-full object-cover object-top group-hover:scale-105 transition-transform duration-500 bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<User className="w-16 h-16 text-muted-foreground/40" />
|
||||
@@ -103,14 +148,26 @@ const StaffSection = () => {
|
||||
roleBadgeClass[member.role] || "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{member.role}
|
||||
{(() => {
|
||||
const roleLabelKey =
|
||||
member.role === "Instructor"
|
||||
? "instructor"
|
||||
: member.role === "DJ"
|
||||
? "dj"
|
||||
: member.role === "Organizador"
|
||||
? "organizer"
|
||||
: undefined;
|
||||
return roleLabelKey ? t(`staff.${roleLabelKey}`) : member.role;
|
||||
})()}
|
||||
</span>
|
||||
|
||||
<h3 className="font-display text-lg font-bold text-foreground mb-2">
|
||||
<h3 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-1">
|
||||
{member.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{member.description}
|
||||
{member.description?.trim().startsWith("[") && member.description?.trim().endsWith("]")
|
||||
? t("staff.placeholder")
|
||||
: member.description}
|
||||
</p>
|
||||
|
||||
{/* Redes sociales */}
|
||||
@@ -122,12 +179,13 @@ const StaffSection = () => {
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Instagram className="w-4 h-4" />
|
||||
Instagram
|
||||
{t("staff.socials.instagram")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Arrows (mobile overlay) */}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* DATOS DEL EVENTO — ZoukLambadaBCN
|
||||
* ===========================================
|
||||
*
|
||||
* Este archivo centraliza TODOS los datos editables del evento.
|
||||
* Para modificar cualquier información, simplemente edita las
|
||||
* constantes de este archivo.
|
||||
* Este archivo centraliza datos "no traducibles" del evento:
|
||||
* fechas, URLs, embeds, imágenes y arrays estructurados.
|
||||
*
|
||||
* El texto visible (títulos, descripciones, labels) vive en:
|
||||
* `src/locales/*.json` y se consume vía i18n.
|
||||
*
|
||||
* NOTA: Las imágenes deben colocarse en src/assets/ e importarse.
|
||||
*/
|
||||
@@ -17,25 +19,30 @@ import pablolena from "@/assets/staff/pablolena.jpg";
|
||||
import djbiel from "@/assets/staff/djbiel.jpg";
|
||||
import djwinx from "@/assets/staff/djwinx.jpg";
|
||||
import djklebynho from "@/assets/staff/djklebynho.jpg";
|
||||
import letialex from "@/assets/staff/letialex.jpg";
|
||||
import djcathie from "@/assets/staff/djcathie.jpg";
|
||||
|
||||
import gal1 from "@/assets/gallery/gal1.jpg";
|
||||
import gal2 from "@/assets/gallery/gal2.jpg";
|
||||
import gal3 from "@/assets/gallery/gal3.jpg";
|
||||
import gal4 from "@/assets/gallery/gal4.jpg";
|
||||
import gal5 from "@/assets/gallery/gal5.jpg";
|
||||
import gal6 from "@/assets/gallery/gal6.jpg";
|
||||
|
||||
|
||||
// ---- INFORMACIÓN GENERAL DEL EVENTO ----
|
||||
export const EVENT_INFO = {
|
||||
name: "ZoukLambadaBCN Beach Festival",
|
||||
subtitle: "by ZoukLambadaBCN",
|
||||
/** Fecha del evento — formato ISO para el countdown */
|
||||
date: "2026-09-04T12:00:00",
|
||||
/** Fecha legible para mostrar */
|
||||
dateDisplay: "04 al 07 de Setiembre de 2026",
|
||||
city: "Santa Susana, Barcelona, España",
|
||||
venue: "[Nombre del Venue]",
|
||||
venueAddress: "[Dirección del venue, Barcelona]",
|
||||
venue: "Hotel Don Angel",
|
||||
venueAddress: "Carrer de la Riera, 123, 08001 Barcelona, Spain",
|
||||
/** Google Maps embed URL — reemplazar con la URL real */
|
||||
mapEmbedUrl: "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2993.5!2d2.1734!3d41.3851!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x0!2zNDHCsDIzJzA2LjQiTiAywrAxMCcyNC4yIkU!5e0!3m2!1ses!2ses!4v1234567890",
|
||||
totalSeats: 150,
|
||||
mapEmbedUrl: "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2982.0132199519758!2d2.718142676474322!3d41.63384568067116!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x12bb3e942c5ac469%3A0x9153afdd8d15d0c1!2sHotel%20Don%20Angel!5e0!3m2!1ses!2sus!4v1777971058774!5m2!1ses!2sus",
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
// ---- WEBHOOK N8N ----
|
||||
/**
|
||||
* CONFIGURACIÓN DEL WEBHOOK:
|
||||
@@ -55,30 +62,15 @@ export const EVENT_INFO = {
|
||||
* }
|
||||
*/
|
||||
export const WEBHOOK_URL = "https://n8n.hacecalor.net/webhook/event-reservation";
|
||||
/**
|
||||
* CLAVE DE AUTENTICACIÓN:
|
||||
*
|
||||
* Si activas "Header Auth" en n8n, asegúrate de que el nombre del header
|
||||
* sea "X-Webhook-Secret" y que este valor coincida con el que pongas allí.
|
||||
*/
|
||||
export const WEBHOOK_SECRET = "oWkS4cAgj0LVgIbnO3cGKTePPLnRAIAa5NTvXahx5z0=";
|
||||
|
||||
// ---- SOBRE EL EVENTO ----
|
||||
export const ABOUT_EVENT = {
|
||||
title: "Sobre el Evento",
|
||||
description: `El ZoukLambadaBCN Beach Festival es uno de los eventos más destacados del panorama internacional de baile, dedicado exclusivamente a las disciplinas del Zouk y la Lambazouk. Se celebra en la encantadora localidad de Santa Susana, en la costa del Maresme, aprovechando su entorno mediterráneo, sus amplias playas y su espíritu acogedor para crear una experiencia única que atrae a bailarines de todos los rincones del mundo. `,
|
||||
lambadaInfo: `La Lambada es un baile brasileño nacido en los años 80,
|
||||
conocido por su sensualidad, conexión y ritmo envolvente.
|
||||
Mezcla influencias de forró, merengue y carimbó,
|
||||
creando una experiencia de baile única y apasionante.`,
|
||||
highlights: [
|
||||
"Workshops con artistas internacionales",
|
||||
"Social dancing durante toda la noche",
|
||||
"Shows en vivo",
|
||||
"DJ sets tropicales",
|
||||
],
|
||||
};
|
||||
|
||||
// ---- SOBRE ZOUKLAMBADABCN ----
|
||||
export const ABOUT_ORG = {
|
||||
title: "ZoukLambadaBCN",
|
||||
history: `[Historia del grupo organizador. Cuándo se fundó,
|
||||
cómo empezó, qué han logrado hasta ahora.]`,
|
||||
philosophy: `[Filosofía del grupo. Qué valores defienden,
|
||||
qué quieren aportar a la comunidad de baile.]`,
|
||||
socials: {
|
||||
instagram: "https://instagram.com/zouklambadabcn",
|
||||
facebook: "https://facebook.com/zouklambadabcn",
|
||||
@@ -178,14 +170,25 @@ export const STAFF = [
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "[Nombre Special Guest]",
|
||||
role: "Special Guest" as const,
|
||||
description: "[Breve biografía del invitado especial]",
|
||||
image: "",
|
||||
name: "[Leticia & Alex]",
|
||||
role: "Instructor" as const,
|
||||
description: "[Breve biografía del instructor]",
|
||||
image: letialex,
|
||||
socials: {
|
||||
instagram: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "[DJ Cathie]",
|
||||
role: "DJ" as const,
|
||||
description: "[Breve biografía del DJ]",
|
||||
image: djcathie,
|
||||
socials: {
|
||||
instagram: "",
|
||||
soundcloud: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ---- PROGRAMA DEL EVENTO ----
|
||||
@@ -255,13 +258,11 @@ export const HOTEL_ROOMS = [
|
||||
// ---- INFORMACIÓN PRÁCTICA ----
|
||||
export const PRACTICAL_INFO = {
|
||||
airports: [
|
||||
{ name: "Aeropuerto de Barcelona-El Prat (BCN)", distance: "~15 km del venue" },
|
||||
{ name: "Aeropuerto de Girona (GRO)", distance: "~100 km del venue" },
|
||||
{ name: "Aeropuerto de Reus (REU)", distance: "~110 km del venue" },
|
||||
{ name: "Aeropuerto de Barcelona-El Prat (BCN)", distance: "~85 km del Hotel" },
|
||||
{ name: "Aeropuerto de Girona (GRO)", distance: "~48 km del Hotel" },
|
||||
],
|
||||
howToGet: [
|
||||
{ method: "Metro", details: "[Línea y parada más cercana]" },
|
||||
{ method: "Bus", details: "[Líneas de bus cercanas]" },
|
||||
{ method: "Metro", details: "Línea y parada más cercana", link: "https://maps.app.goo.gl/YVyASwX2odX1mW5J6" },
|
||||
{ method: "Taxi/Uber", details: "Disponible desde cualquier punto de Barcelona" },
|
||||
],
|
||||
};
|
||||
@@ -272,27 +273,87 @@ export const PRACTICAL_INFO = {
|
||||
* Para importar: import img from "@/assets/gallery/photo1.jpg"
|
||||
*/
|
||||
export const GALLERY_IMAGES = [
|
||||
{ src: "", alt: "[Descripción foto 1]" },
|
||||
{ src: "", alt: "[Descripción foto 2]" },
|
||||
{ src: "", alt: "[Descripción foto 3]" },
|
||||
{ src: "", alt: "[Descripción foto 4]" },
|
||||
{ src: "", alt: "[Descripción foto 5]" },
|
||||
{ src: "", alt: "[Descripción foto 6]" },
|
||||
{ src: gal1, alt: "[Descripción foto 1]" },
|
||||
{ src: gal2, alt: "[Descripción foto 2]" },
|
||||
{ src: gal3, alt: "[Descripción foto 3]" },
|
||||
{ src: gal4, alt: "[Descripción foto 4]" },
|
||||
{ src: gal5, alt: "[Descripción foto 5]" },
|
||||
{ src: gal6, alt: "[Descripción foto 6]" },
|
||||
];
|
||||
|
||||
// ---- PAQUETES MIXTOS (Room + Pass) ----
|
||||
export type RoomType = "individual" | "double" | "suite";
|
||||
export type PassType = "full" | "party";
|
||||
|
||||
export const ROOM_TYPES: { id: RoomType }[] = [
|
||||
{ id: "individual" },
|
||||
{ id: "double" },
|
||||
{ id: "suite" },
|
||||
];
|
||||
|
||||
// ---- PRECIOS DINÁMICOS FULL PASS ----
|
||||
/**
|
||||
* Calcula el precio del Full Pass según la fecha actual.
|
||||
* - Hasta 1 de julio: 170€
|
||||
* - 1 de julio - 1 de septiembre: 190€
|
||||
* - Después del inicio del evento: 200€ (último precio)
|
||||
*/
|
||||
export function getFullPassPrice(): { price: number; isLastPrice: boolean } {
|
||||
const now = new Date();
|
||||
const julyFirst = new Date("2026-07-01T00:00:00");
|
||||
const septemberFirst = new Date("2026-09-01T00:00:00");
|
||||
const eventStart = new Date(EVENT_INFO.date);
|
||||
|
||||
if (now < julyFirst) {
|
||||
return { price: 170, isLastPrice: false };
|
||||
} else if (now < septemberFirst) {
|
||||
return { price: 190, isLastPrice: false };
|
||||
} else {
|
||||
// After event starts
|
||||
return { price: 200, isLastPrice: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const MIXED_BOOKING_PACKAGES = [
|
||||
{ id: "full" as PassType, label: "full", roomPrices: { individual: 100, double: 150, suite: 213 } },
|
||||
{ id: "party" as PassType, label: "party", roomPrices: { individual: 100, double: 150, suite: 213 } },
|
||||
];
|
||||
|
||||
// ---- SECCIONES VISIBLES ----
|
||||
export const SECTIONS = {
|
||||
about: true,
|
||||
org: true,
|
||||
staff: true,
|
||||
profesores: true,
|
||||
schedule: true,
|
||||
booking: false,
|
||||
mixed_booking: true,
|
||||
hotel: false,
|
||||
practical: true,
|
||||
gallery: true,
|
||||
};
|
||||
|
||||
// ---- NAVEGACIÓN ----
|
||||
export const NAV_LINKS = [
|
||||
{ label: "Sobre", href: "#about" },
|
||||
{ label: "Staff", href: "#staff" },
|
||||
{ label: "Programa", href: "#schedule" },
|
||||
{ label: "Reservar", href: "#booking" },
|
||||
{ label: "Hotel", href: "#hotel" },
|
||||
{ label: "Info", href: "#info" },
|
||||
{ label: "Galería", href: "#gallery" },
|
||||
];
|
||||
|
||||
// ---- FOOTER ----
|
||||
export const FOOTER = {
|
||||
email: "[email@zouklambadabcn.com]",
|
||||
copyright: `© ${new Date().getFullYear()} ZoukLambadaBCN. Todos los derechos reservados.`,
|
||||
const HREF_TO_SECTION: Record<string, keyof typeof SECTIONS> = {
|
||||
"#about": "about",
|
||||
"#staff": "staff",
|
||||
"#schedule": "schedule",
|
||||
"#booking": "booking",
|
||||
"#mixed-booking": "mixed_booking",
|
||||
"#hotel": "hotel",
|
||||
"#info": "practical",
|
||||
"#gallery": "gallery",
|
||||
};
|
||||
|
||||
export const NAV_LINKS = (
|
||||
[
|
||||
{ href: "#about" },
|
||||
{ href: "#staff" },
|
||||
{ href: "#schedule" },
|
||||
{ href: "#booking" },
|
||||
{ href: "#mixed-booking" },
|
||||
{ href: "#hotel" },
|
||||
{ href: "#info" },
|
||||
{ href: "#gallery" },
|
||||
] as const
|
||||
).filter((link) => SECTIONS[HREF_TO_SECTION[link.href]]);
|
||||
|
||||
23
src/i18n.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import es from './locales/es.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
const resources = {
|
||||
es: { translation: es },
|
||||
en: { translation: en },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
lng: 'es', // Default language
|
||||
fallbackLng: 'es',
|
||||
interpolation: {
|
||||
escapeValue: false, // React already handles escaping
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
128
src/index.css
@@ -1,4 +1,4 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700;800&family=Pacifico&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -12,85 +12,85 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Paleta: Egyptian Blue, Slate Indigo, Baby Blue Ice, Dark Amethyst, Dusty Grape */
|
||||
--background: 225 30% 96%;
|
||||
--foreground: 260 32% 24%;
|
||||
/* Paleta: Beachy, Sandy, Warm Orange */
|
||||
--background: 40 40% 94%; /* Sandy beach */
|
||||
--foreground: 25 40% 25%; /* Warm dark brown/orange */
|
||||
|
||||
--card: 225 25% 94%;
|
||||
--card-foreground: 260 32% 24%;
|
||||
--card: 40 50% 98%; /* Light sand */
|
||||
--card-foreground: 25 40% 25%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 260 32% 24%;
|
||||
--popover: 40 50% 98%;
|
||||
--popover-foreground: 25 40% 25%;
|
||||
|
||||
/* Primary: Egyptian Blue */
|
||||
--primary: 231 66% 37%;
|
||||
/* Primary: Vibrant Orange */
|
||||
--primary: 20 90% 55%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary: Slate Indigo */
|
||||
--secondary: 225 34% 48%;
|
||||
/* Secondary: Golden Orange */
|
||||
--secondary: 35 90% 50%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
/* Accent: Baby Blue Ice */
|
||||
--accent: 220 81% 75%;
|
||||
--accent-foreground: 260 32% 24%;
|
||||
/* Accent: Warm Peach */
|
||||
--accent: 25 80% 80%;
|
||||
--accent-foreground: 25 40% 25%;
|
||||
|
||||
--muted: 225 20% 90%;
|
||||
--muted-foreground: 227 20% 42%;
|
||||
--muted: 40 30% 85%;
|
||||
--muted-foreground: 25 30% 45%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 225 20% 85%;
|
||||
--input: 225 20% 85%;
|
||||
--ring: 231 66% 37%;
|
||||
--border: 40 25% 82%;
|
||||
--input: 40 25% 82%;
|
||||
--ring: 20 90% 55%;
|
||||
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Custom tokens */
|
||||
--gradient-tropical: linear-gradient(135deg, hsla(28.2, 80.4%, 44.1%, 0.91), hsla(27.1, 88.4%, 37.3%, 0.76), hsl(22.3, 95.5%, 56.9%));
|
||||
--gradient-warm: linear-gradient(180deg, hsl(225 30% 96%), hsl(225 25% 92%));
|
||||
--shadow-glow: 0 0 40px hsl(231 66% 37% / 0.3);
|
||||
--shadow-card: 0 8px 30px hsl(260 32% 24% / 0.08);
|
||||
--shadow-elevated: 0 20px 50px hsl(260 32% 24% / 0.12);
|
||||
--gradient-tropical: linear-gradient(135deg, hsla(15, 90%, 55%, 0.9), hsla(30, 90%, 55%, 0.8), hsl(45, 90%, 50%));
|
||||
--gradient-warm: linear-gradient(180deg, hsl(40 40% 94%), hsl(40 30% 85%));
|
||||
--shadow-glow: 0 0 40px hsl(20 90% 55% / 0.3);
|
||||
--shadow-card: 0 8px 30px hsl(25 40% 25% / 0.08);
|
||||
--shadow-elevated: 0 20px 50px hsl(25 40% 25% / 0.12);
|
||||
/* Sidebar (unused but required) */
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: 40 50% 98%;
|
||||
--sidebar-foreground: 25 40% 25%;
|
||||
--sidebar-primary: 20 90% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 40 30% 90%;
|
||||
--sidebar-accent-foreground: 25 40% 25%;
|
||||
--sidebar-border: 40 25% 82%;
|
||||
--sidebar-ring: 20 90% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 260 30% 8%;
|
||||
--foreground: 220 81% 90%;
|
||||
--card: 260 28% 12%;
|
||||
--card-foreground: 220 81% 90%;
|
||||
--popover: 260 28% 12%;
|
||||
--popover-foreground: 220 81% 90%;
|
||||
--primary: 220 81% 75%;
|
||||
--primary-foreground: 260 32% 24%;
|
||||
--secondary: 225 34% 48%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--accent: 231 66% 45%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--muted: 260 20% 18%;
|
||||
--muted-foreground: 225 20% 65%;
|
||||
--background: 35 25% 12%; /* Dark beach night */
|
||||
--foreground: 30 50% 96%;
|
||||
--card: 35 20% 15%;
|
||||
--card-foreground: 30 50% 96%;
|
||||
--popover: 35 20% 15%;
|
||||
--popover-foreground: 30 50% 96%;
|
||||
--primary: 20 90% 60%;
|
||||
--primary-foreground: 30 90% 10%;
|
||||
--secondary: 35 90% 55%;
|
||||
--secondary-foreground: 30 90% 10%;
|
||||
--accent: 25 60% 30%;
|
||||
--accent-foreground: 30 50% 96%;
|
||||
--muted: 35 20% 22%;
|
||||
--muted-foreground: 30 30% 70%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 260 15% 20%;
|
||||
--input: 260 15% 20%;
|
||||
--ring: 220 81% 75%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--border: 35 20% 25%;
|
||||
--input: 35 20% 25%;
|
||||
--ring: 20 90% 60%;
|
||||
--sidebar-background: 35 20% 15%;
|
||||
--sidebar-foreground: 30 50% 96%;
|
||||
--sidebar-primary: 20 90% 60%;
|
||||
--sidebar-primary-foreground: 30 90% 10%;
|
||||
--sidebar-accent: 35 20% 22%;
|
||||
--sidebar-accent-foreground: 30 50% 96%;
|
||||
--sidebar-border: 35 20% 25%;
|
||||
--sidebar-ring: 20 90% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
padding-bottom: 0.3em; /* Prevents descenders like j, g, p from being clipped! */
|
||||
margin-bottom: -0.3em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Tropical gradient background */
|
||||
@@ -135,6 +138,7 @@
|
||||
/* Card shadow */
|
||||
.shadow-card {
|
||||
box-shadow: var(--shadow-card);
|
||||
background-color: hsl(40, 40%, 94%);
|
||||
}
|
||||
|
||||
.shadow-elevated {
|
||||
@@ -145,4 +149,12 @@
|
||||
.section-padding {
|
||||
@apply px-4 py-16 md:px-8 md:py-24 lg:px-16;
|
||||
}
|
||||
|
||||
.bg-background-white {
|
||||
background-color: hsla(40, 40%, 94.1%, 0.2);
|
||||
}
|
||||
|
||||
.text-black {
|
||||
color: hsla(0, 0%, 0%, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
185
src/locales/en.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"nav": {
|
||||
"about": "About",
|
||||
"staff": "Staff",
|
||||
"schedule": "Schedule",
|
||||
"booking": "Book your pass",
|
||||
"mixed-booking": "Packs",
|
||||
"hotel": "Hotel",
|
||||
"info": "Info",
|
||||
"gallery": "Gallery"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Zouk Lambada Barcelona",
|
||||
"subtitle": "Beach Festival",
|
||||
"dates": "04 - 07 September 2026",
|
||||
"location": "Barcelona, Spain",
|
||||
"registerButton": "Register Now",
|
||||
"whatsappButton": "Join WhatsApp Group",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"minutes": "Min",
|
||||
"seconds": "Sec",
|
||||
"eventInProgress": "EVENT IN PROGRESS",
|
||||
"dontMiss": "Don't miss it!",
|
||||
"bookYourPass": "Book Your Pass"
|
||||
},
|
||||
"about": {
|
||||
"title": "About the Event",
|
||||
"description": "Join us for an unforgettable weekend of dance, music, and connection in the heart of Barcelona. Our festival brings together world-renowned instructors and dance enthusiasts of all levels.",
|
||||
"orgTitle": "About the Organization",
|
||||
"orgDescription": "We are a passionate community of dancers dedicated to promoting Zouk and Lambada culture in Barcelona. Our team is committed to creating a magical experience for all participants.",
|
||||
"lambadaInfo": "Lambada is a Brazilian dance born in the 80s, known for its sensuality, connection, and enveloping rhythm. It mixes influences from forró, merengue, and carimbó, creating a unique and passionate dance experience.",
|
||||
"highlights": {
|
||||
"workshops": "Workshops with international artists",
|
||||
"socialDancing": "Social dancing all night long",
|
||||
"liveShows": "Live shows",
|
||||
"djSets": "Tropical DJ sets"
|
||||
}
|
||||
},
|
||||
"staff": {
|
||||
"title": "Our Team",
|
||||
"subtitle": "Event Staff",
|
||||
"description": "Meet the artists and instructors who will make this festival an unforgettable experience.",
|
||||
"instructor": "Instructor",
|
||||
"dj": "DJ",
|
||||
"organizer": "Organizer",
|
||||
"placeholder": "[Instructor biography]",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"instructors": "Instructors",
|
||||
"djs": "DJs"
|
||||
},
|
||||
"socials": {
|
||||
"instagram": "Instagram"
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule",
|
||||
"subtitle": "Three days of workshops, shows and social dance.",
|
||||
"friday": "Friday, September 04",
|
||||
"saturday": "Saturday, September 05",
|
||||
"sunday": "Sunday, September 06",
|
||||
"workshop": "Workshop",
|
||||
"break": "Break",
|
||||
"social": "Social Dance",
|
||||
"show": "Live Shows",
|
||||
"farewell": "Farewell Party",
|
||||
"statusBriefTitle": "Schedule updates soon",
|
||||
"statusBriefDescription": "Stay tuned for the latest updates!",
|
||||
"partyPassInfo": "The Party Pass includes access to Friday, Saturday, and Sunday parties. The Full Pass includes access to all parties and workshops."
|
||||
},
|
||||
"booking": {
|
||||
"title": "Booking your pass",
|
||||
"subtitle": "Reserve your spot",
|
||||
"description": "Complete the form to reserve your spot. We will send you a confirmation email with payment details.",
|
||||
"passes": "Event Passes",
|
||||
"fullPass": "Full Pass",
|
||||
"partyPass": "Party Pass",
|
||||
"singleDayPass": "Single Day Pass",
|
||||
"fullPassDescription": "Full access to all workshops, socials, and festival activities",
|
||||
"partyPassDescription": "Access to all parties (Friday, Saturday, and Sunday)",
|
||||
"singleDayPassDescription": "Access to a single day of the festival",
|
||||
"price": "Price",
|
||||
"quantity": "Quantity",
|
||||
"totalPrice": "Total Price",
|
||||
"formFields": {
|
||||
"name": "Name",
|
||||
"surname": "Surname",
|
||||
"email": "Email",
|
||||
"passType": "Pass Type",
|
||||
"amount": "Amount",
|
||||
"country": "Country",
|
||||
"countryPlaceholder": "Search country...",
|
||||
"countryNoResults": "No countries found"
|
||||
},
|
||||
"validation": {
|
||||
"nameMin": "Name must be at least 2 characters",
|
||||
"surnameMin": "Surname must be at least 2 characters",
|
||||
"emailInvalid": "Invalid email",
|
||||
"passTypeRequired": "Select a pass type",
|
||||
"amountRequired": "Select amount",
|
||||
"countryMin": "Indicate your country"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Sending...",
|
||||
"successTitle": "Reservation received!",
|
||||
"success": "Reservation sent successfully! We will contact you soon.",
|
||||
"eventInProgressTitle": "Event in progress!",
|
||||
"eventInProgressDescription": "Disabled reservations.",
|
||||
"error": "Error sending reservation. Please try again."
|
||||
},
|
||||
"priceSummaryLabel": "Total Price",
|
||||
"priceSummaryNote": "No online payment • Pay at the event",
|
||||
"buyButton": "Send Reservation",
|
||||
"reservationId": "Reservation ID"
|
||||
},
|
||||
"hotel": {
|
||||
"title": "Accommodation",
|
||||
"subtitle": "Recommended rooms near the venue.",
|
||||
"individual": "[Single Room]",
|
||||
"double": "[Double Room]",
|
||||
"suite": "[Suite]",
|
||||
"pricePerNight": "[XX€/night]",
|
||||
"description": "[Brief room description]",
|
||||
"bookButton": "Book at hotel"
|
||||
},
|
||||
"mixedBooking": {
|
||||
"title": "Room + Pass Bundles",
|
||||
"subtitle": "Room + Pass bundles for your stay.",
|
||||
"priceTBD": "Price TBA",
|
||||
"selectRoom": "Choose room type",
|
||||
"popular": "Popular",
|
||||
"lastPrice": "Last Price",
|
||||
"perPerson": "/ person",
|
||||
"perParty": "/ party",
|
||||
"hotelQuestion": "Do you need a hotel room?",
|
||||
"hotelYes": "Yes",
|
||||
"hotelNo": "No",
|
||||
"nonHotelNote": "+50€ fee for not staying in hotel",
|
||||
"roomTypes": {
|
||||
"individual": "Single Room",
|
||||
"double": "Double Room",
|
||||
"suite": "Triple Room"
|
||||
},
|
||||
"passTypes": {
|
||||
"full": "Full Pass",
|
||||
"party": "Party Pass"
|
||||
},
|
||||
"fullDescription": "Full access to all workshops, socials, and festival activities",
|
||||
"fullFeatures": "All workshops|All social dances|Live shows|DJ sets",
|
||||
"partyDescription": "Price for each party individually, even pool parties.",
|
||||
"partyFeatures": "Friday party|Saturday party|Sunday farewell party|DJ sets"
|
||||
},
|
||||
"info": {
|
||||
"title": "Practical Information",
|
||||
"airports": "Nearby Airports",
|
||||
"howToGet": "How to Get There",
|
||||
"metro": "Metro",
|
||||
"bus": "Bus",
|
||||
"taxi": "Taxi/Uber",
|
||||
"nearestStop": "[Nearest line and stop]",
|
||||
"busLines": "[Nearby bus lines]",
|
||||
"taxiDetails": "Available from anywhere in Barcelona",
|
||||
"venue": "Event Venue",
|
||||
"mapTitle": "Event Location",
|
||||
"viewOnMap": "View on map"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Gallery"
|
||||
},
|
||||
"common": {
|
||||
"toTop": "Back to top"
|
||||
},
|
||||
"footer": {
|
||||
"title": "ZoukLambadaBCN",
|
||||
"contact": "Contact",
|
||||
"followUs": "Follow Us",
|
||||
"email": "[email@zouklambadabcn.com]",
|
||||
"copyright": "© {{year}} ZoukLambadaBCN. All rights reserved."
|
||||
},
|
||||
"language": {
|
||||
"es": "ES",
|
||||
"en": "EN"
|
||||
}
|
||||
}
|
||||
185
src/locales/es.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"nav": {
|
||||
"about": "Sobre",
|
||||
"staff": "Staff",
|
||||
"schedule": "Programa",
|
||||
"booking": "Reservar",
|
||||
"mixed-booking": "Packs",
|
||||
"hotel": "Hotel",
|
||||
"info": "Info",
|
||||
"gallery": "Galería"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Zouk Lambada Barcelona",
|
||||
"subtitle": "Beach Festival",
|
||||
"dates": "04 - 07 Setiembre 2026",
|
||||
"location": "Barcelona, España",
|
||||
"registerButton": "Registrarse Ahora",
|
||||
"whatsappButton": "Unirse al Grupo de WhatsApp",
|
||||
"days": "Días",
|
||||
"hours": "Horas",
|
||||
"minutes": "Min",
|
||||
"seconds": "Seg",
|
||||
"eventInProgress": "EVENTO EN CURSO",
|
||||
"dontMiss": "¡No te lo pierdas!",
|
||||
"bookYourPass": "Reservar tu pase"
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre el Evento",
|
||||
"description": "Únete a nosotros para un fin de semana inolvidable de baile, música y conexión en el corazón de Barcelona. Nuestro festival reúne a instructores de renombre mundial y entusiastas del baile de todos los niveles.",
|
||||
"orgTitle": "Sobre la Organización",
|
||||
"orgDescription": "Somos una comunidad apasionada de bailarines dedicados a promover la cultura del Zouk y Lambada en Barcelona. Nuestro equipo está comprometido en crear una experiencia mágica para todos los participantes.",
|
||||
"lambadaInfo": "La Lambada es un baile brasileño nacido en los años 80, conocido por su sensualidad, conexión y ritmo envolvente. Mezcla influencias de forró, merengue y carimbó, creando una experiencia de baile única y apasionante.",
|
||||
"highlights": {
|
||||
"workshops": "Workshops con artistas internacionales",
|
||||
"socialDancing": "Social dancing durante toda la noche",
|
||||
"liveShows": "Shows en vivo",
|
||||
"djSets": "DJ sets tropicales"
|
||||
}
|
||||
},
|
||||
"staff": {
|
||||
"title": "Nuestro Equipo",
|
||||
"subtitle": "Staff del Evento",
|
||||
"description": "Conoce a los artistas e instructores que harán de este festival una experiencia inolvidable.",
|
||||
"instructor": "Instructor",
|
||||
"dj": "DJ",
|
||||
"organizer": "Organizador",
|
||||
"placeholder": "[Breve biografía del instructor]",
|
||||
"filters": {
|
||||
"all": "Todos",
|
||||
"instructors": "Instructores",
|
||||
"djs": "DJs"
|
||||
},
|
||||
"socials": {
|
||||
"instagram": "Instagram"
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Programa",
|
||||
"subtitle": "Tres días de workshops, shows y social dance.",
|
||||
"friday": "Viernes 04 Setiembre",
|
||||
"saturday": "Sábado 05 Setiembre",
|
||||
"sunday": "Domingo 06 Setiembre",
|
||||
"workshop": "Workshop",
|
||||
"break": "Pausa",
|
||||
"social": "Social Dance",
|
||||
"show": "Shows en Vivo",
|
||||
"farewell": "Farewell Party",
|
||||
"statusBriefTitle": "Información sobre horarios en breve",
|
||||
"statusBriefDescription": "Mantente atento a las actualizaciones!",
|
||||
"partyPassInfo": "El Party Pass incluye acceso a las fiestas de Viernes, Sábado y Domingo. El Full Pass incluye acceso a todas las fiestas y workshops."
|
||||
},
|
||||
"booking": {
|
||||
"title": "Reservas",
|
||||
"subtitle": "Reserva tu plaza",
|
||||
"description": "Completa el formulario para reservar tu plaza. Te enviaremos un email de confirmación con los detalles del pago.",
|
||||
"passes": "Pases del Evento",
|
||||
"fullPass": "Full Pass",
|
||||
"partyPass": "Party Pass",
|
||||
"singleDayPass": "Single Day Pass",
|
||||
"fullPassDescription": "Acceso completo a todos los workshops, sociales y actividades del festival",
|
||||
"partyPassDescription": "Acceso a todas las fiestas (Viernes, Sábado y Domingo)",
|
||||
"singleDayPassDescription": "Acceso a un solo día del festival",
|
||||
"price": "Precio",
|
||||
"quantity": "Cantidad",
|
||||
"totalPrice": "Precio Total",
|
||||
"formFields": {
|
||||
"name": "Nombre",
|
||||
"surname": "Apellido",
|
||||
"email": "Email",
|
||||
"passType": "Tipo de Pase",
|
||||
"amount": "Cantidad",
|
||||
"country": "País",
|
||||
"countryPlaceholder": "Buscar país...",
|
||||
"countryNoResults": "No se encontraron países"
|
||||
},
|
||||
"validation": {
|
||||
"nameMin": "El nombre debe tener al menos 2 caracteres",
|
||||
"surnameMin": "El apellido debe tener al menos 2 caracteres",
|
||||
"emailInvalid": "Email no válido",
|
||||
"passTypeRequired": "Selecciona un tipo de pass",
|
||||
"amountRequired": "Selecciona la cantidad",
|
||||
"countryMin": "Indica tu país"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Enviando...",
|
||||
"successTitle": "¡Reserva recibida!",
|
||||
"success": "¡Reserva enviada con éxito! Te contactaremos pronto.",
|
||||
"eventInProgressTitle": "¡Evento en curso!",
|
||||
"eventInProgressDescription": "La reserva de pases ha finalizado.",
|
||||
"error": "Error al enviar la reserva. Inténtalo de nuevo."
|
||||
},
|
||||
"priceSummaryLabel": "Precio Total",
|
||||
"priceSummaryNote": "Sin pago online • Paga en el evento",
|
||||
"buyButton": "Enviar Reserva",
|
||||
"reservationId": "ID de Reserva"
|
||||
},
|
||||
"hotel": {
|
||||
"title": "Alojamiento",
|
||||
"subtitle": "Habitaciones recomendadas cerca del venue.",
|
||||
"individual": "[Habitación Individual]",
|
||||
"double": "[Habitación Doble]",
|
||||
"suite": "[Suite]",
|
||||
"pricePerNight": "[XX€/noche]",
|
||||
"description": "[Descripción breve de la habitación]",
|
||||
"bookButton": "Reservar en el hotel"
|
||||
},
|
||||
"mixedBooking": {
|
||||
"title": "Packs de Habitación + Pase",
|
||||
"subtitle": "Combina habitación + pase para tu estancia.",
|
||||
"priceTBD": "Precio por confirmar",
|
||||
"selectRoom": "Elige tipo de habitación",
|
||||
"popular": "Popular",
|
||||
"lastPrice": "Último Precio",
|
||||
"perPerson": "/ persona",
|
||||
"perParty": "/ fiesta",
|
||||
"hotelQuestion": "¿Necesitas habitación de hotel?",
|
||||
"hotelYes": "Sí",
|
||||
"hotelNo": "No",
|
||||
"nonHotelNote": "+50€ por no alojarse en hotel",
|
||||
"roomTypes": {
|
||||
"individual": "Habitación Individual",
|
||||
"double": "Habitación Doble",
|
||||
"suite": "Habitación Triple"
|
||||
},
|
||||
"passTypes": {
|
||||
"full": "Full Pass",
|
||||
"party": "Party Pass"
|
||||
},
|
||||
"fullDescription": "Acceso completo a todos los workshops, sociales y actividades del festival",
|
||||
"fullFeatures": "Todos los workshops|Todas las social dances|Shows en vivo|DJ sets",
|
||||
"partyDescription": "Precio individual por cada fiesta, incluso las pool parties.",
|
||||
"partyFeatures": "Fiesta del viernes|Fiesta del sábado|Fiesta despedida del domingo|DJ sets"
|
||||
},
|
||||
"info": {
|
||||
"title": "Información Práctica",
|
||||
"airports": "Aeropuertos Cercanos",
|
||||
"howToGet": "Cómo Llegar",
|
||||
"metro": "Metro",
|
||||
"bus": "Bus",
|
||||
"taxi": "Taxi/Uber",
|
||||
"nearestStop": "[Línea y parada más cercana]",
|
||||
"busLines": "[Líneas de bus cercanas]",
|
||||
"taxiDetails": "Disponible desde cualquier punto de Barcelona",
|
||||
"venue": "Lugar del Evento",
|
||||
"mapTitle": "Ubicación del evento",
|
||||
"viewOnMap": "Ver en mapa"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galería"
|
||||
},
|
||||
"common": {
|
||||
"toTop": "Volver arriba"
|
||||
},
|
||||
"footer": {
|
||||
"title": "ZoukLambadaBCN",
|
||||
"contact": "Contacto",
|
||||
"followUs": "Síguenos",
|
||||
"email": "[info@zouklambadabcn.com]",
|
||||
"copyright": "© {{year}} ZoukLambadaBCN. Todos los derechos reservados."
|
||||
},
|
||||
"language": {
|
||||
"es": "ES",
|
||||
"en": "EN"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./i18n.ts";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -6,11 +6,13 @@ import StaffSection from "@/components/StaffSection";
|
||||
import ProfesoresSection from "@/components/ProfesoresSection";
|
||||
import ScheduleSection from "@/components/ScheduleSection";
|
||||
import BookingSection from "@/components/BookingSection";
|
||||
import MixedBookingSection from "@/components/MixedBookingSection";
|
||||
import HotelSection from "@/components/HotelSection";
|
||||
import PracticalSection from "@/components/PracticalSection";
|
||||
import GallerySection from "@/components/GallerySection";
|
||||
import FooterSection from "@/components/FooterSection";
|
||||
import FloatingButton from "@/components/FloatingButton";
|
||||
import { SECTIONS } from "@/data/event-data";
|
||||
|
||||
/**
|
||||
* Landing page — Lambada Festival Barcelona
|
||||
@@ -23,15 +25,16 @@ const Index = () => {
|
||||
<div className="min-h-screen">
|
||||
<Navbar />
|
||||
<HeroSection />
|
||||
<AboutSection />
|
||||
<OrgSection />
|
||||
<StaffSection />
|
||||
<ProfesoresSection />
|
||||
<ScheduleSection />
|
||||
<BookingSection />
|
||||
<HotelSection />
|
||||
<PracticalSection />
|
||||
<GallerySection />
|
||||
{SECTIONS.about && <AboutSection />}
|
||||
{SECTIONS.org && <OrgSection />}
|
||||
{SECTIONS.staff && <StaffSection />}
|
||||
{SECTIONS.profesores && <ProfesoresSection />}
|
||||
{SECTIONS.schedule && <ScheduleSection />}
|
||||
{SECTIONS.booking && <BookingSection />}
|
||||
{SECTIONS.mixed_booking && <MixedBookingSection />}
|
||||
{SECTIONS.hotel && <HotelSection />}
|
||||
{SECTIONS.practical && <PracticalSection />}
|
||||
{SECTIONS.gallery && <GallerySection />}
|
||||
<FooterSection />
|
||||
<FloatingButton />
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,8 @@ export default {
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['"Playfair Display"', "Georgia", "serif"],
|
||||
display: ['"Pacifico"', "cursive"],
|
||||
hero: ['"Noto Sans"', "sans-serif"],
|
||||
body: ['"Inter"', "system-ui", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
@@ -24,7 +25,7 @@ export default {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(230.5, 57.6%, 74.1%)",
|
||||
DEFAULT: "hsla(37, 93%, 53%, 1.00)",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
|
||||
149
ticket-template.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Registro de Ticket - ZoukLambadaBCN</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pacifico&family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Email client resets */
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||
table { border-collapse: collapse !important; }
|
||||
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; background-color: #fcf6ef; }
|
||||
|
||||
/* Custom Styles */
|
||||
.primary-text { color: #fb923c !important; }
|
||||
.font-display { font-family: 'Pacifico', cursive; }
|
||||
.font-body { font-family: 'Noto Sans', 'Helvetica', 'Arial', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #fcf6ef;">
|
||||
<center>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; margin-top: 40px; margin-bottom: 40px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 30px rgba(90, 62, 30, 0.05);">
|
||||
|
||||
<!-- Header with Wave/Beach style -->
|
||||
<tr>
|
||||
<td align="center" style="background: linear-gradient(135deg, #fb923c, #f97316); padding: 40px 20px;">
|
||||
<h1 class="font-display" style="color: #ffffff; margin: 0; font-size: 36px; text-shadow: 2px 2px 0px rgba(0,0,0,0.1);">
|
||||
ZoukLambadaBCN
|
||||
</h1>
|
||||
<p class="font-body" style="color: #ffe7d1; margin: 10px 0 0; font-size: 14px; text-transform: uppercase; letter-spacing: 2px; font-weight: 700;">
|
||||
Beach Festival 2026
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content Area -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 30px;" class="font-body">
|
||||
<h2 style="color: #5a3e1e; margin: 0 0 15px; font-size: 24px;">¡Hola, {{name}}! 👋</h2>
|
||||
<p style="color: #7c5a3d; line-height: 1.6; margin: 0 0 30px; font-size: 16px;">
|
||||
Tu registro para el <strong>ZoukLambadaBCN Beach Festival</strong> ha sido recibido correctamente. Estamos emocionados de tenerte con nosotros este Septiembre.
|
||||
</p>
|
||||
|
||||
<!-- Ticket Information Box -->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #fdf8f4; border: 1px dashed #fb923c; border-radius: 15px; padding: 25px;">
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom: 10px; border-bottom: 1px solid #eee;">
|
||||
<span style="font-size: 12px; color: #9a7a5c; text-transform: uppercase; font-weight: bold;">Ticket de Referencia:</span><br>
|
||||
<span style="font-size: 18px; color: #5a3e1e; font-weight: 700;">#{{requestId}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding: 15px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: #5a3e1e; font-size: 16px;">{{passType}} x{{amount}}</td>
|
||||
<td align="right" style="color: #fb923c; font-size: 20px; font-weight: 800;">{{price}}€</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color: #ffffff; border-radius: 8px; padding: 10px; border: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 13px; color: #9a7a5c; font-style: italic;">
|
||||
⚠️ Recuerda: No se requiere pago online. El pago total de <strong>{{price}}€</strong> se realizará directamente en el mostrador del evento.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Location / Date summary -->
|
||||
<p style="margin: 30px 0 0; font-size: 14px; color: #9a7a5c;">
|
||||
📅 <strong>04 - 07 Sept, 2026</strong><br>
|
||||
📍 Santa Susana, Barcelona
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Separator -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 30px;">
|
||||
<hr style="border: 0; border-top: 1px solid #eee; margin: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content Area (English) -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 30px;" class="font-body">
|
||||
<h2 style="color: #5a3e1e; margin: 0 0 15px; font-size: 24px;">Hi, {{name}}! 👋</h2>
|
||||
<p style="color: #7c5a3d; line-height: 1.6; margin: 0 0 30px; font-size: 16px;">
|
||||
Your registration for the <strong>ZoukLambadaBCN Beach Festival</strong> has been successfully received. We are excited to have you with us this September.
|
||||
</p>
|
||||
|
||||
<!-- Ticket Information Box -->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #fdf8f4; border: 1px dashed #fb923c; border-radius: 15px; padding: 25px;">
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom: 10px; border-bottom: 1px solid #eee;">
|
||||
<span style="font-size: 12px; color: #9a7a5c; text-transform: uppercase; font-weight: bold;">Reference Ticket:</span><br>
|
||||
<span style="font-size: 18px; color: #5a3e1e; font-weight: 700;">#{{requestId}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding: 15px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: #5a3e1e; font-size: 16px;">{{passType}} x{{amount}}</td>
|
||||
<td align="right" style="color: #fb923c; font-size: 20px; font-weight: 800;">{{price}}€</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color: #ffffff; border-radius: 8px; padding: 10px; border: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 13px; color: #9a7a5c; font-style: italic;">
|
||||
⚠️ Remember: No online payment required. The total payment of <strong>{{price}}€</strong> will be made directly at the event's front desk.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Location / Date summary -->
|
||||
<p style="margin: 30px 0 0; font-size: 14px; color: #9a7a5c;">
|
||||
📅 <strong>04 - 07 Sept, 2026</strong><br>
|
||||
📍 Santa Susana, Barcelona
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Social / Footer -->
|
||||
<tr>
|
||||
<td align="center" style="background-color: #fdf8f4; padding: 30px; border-top: 1px solid #efefef;">
|
||||
<p style="margin: 0 0 15px; font-size: 14px; color: #7c5a3d; font-weight: bold;">¡Síguenos y prepárate! / Follow us and get ready!</p>
|
||||
<a href="https://instagram.com/zouklambadabcn" style="text-decoration: none; display: inline-block; margin: 0 5px;">
|
||||
<img src="https://cdn-icons-png.flaticon.com/32/174/174855.png" width="24" height="24" alt="Instagram">
|
||||
</a>
|
||||
<p style="margin: 20px 0 0; font-size: 12px; color: #9a7a5c;">
|
||||
© 2026 ZoukLambadaBCN. Todos los derechos reservados.<br>
|
||||
Si no realizaste este registro, por favor ignora este correo.<br>
|
||||
<em>If you did not perform this registration, please ignore this email.</em>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
</html>
|
||||
15
update_animations.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dir = 'src/components';
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
if (content.includes('viewport={{ once: true }}')) {
|
||||
content = content.replaceAll('viewport={{ once: true }}', 'viewport={{ once: false, amount: 0.15 }}');
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(`Updated ${file}`);
|
||||
}
|
||||
}
|
||||
35
update_sizes.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dir = 'src/components';
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
|
||||
|
||||
for (const file of files) {
|
||||
if (file === 'HeroSection.tsx') continue;
|
||||
|
||||
const filePath = path.join(dir, file);
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let original = content;
|
||||
|
||||
// For main H2s
|
||||
content = content.replaceAll('text-4xl md:text-5xl font-bold', 'text-5xl md:text-6xl font-bold py-2 leading-[1.3] pb-4');
|
||||
|
||||
// For smaller H2s/H3s
|
||||
content = content.replaceAll('text-lg font-bold text-foreground mb-2', 'text-2xl py-2 leading-relaxed font-bold text-foreground mb-1');
|
||||
content = content.replaceAll('text-xl font-bold text-foreground mb-6', 'text-3xl py-2 leading-relaxed font-bold text-foreground mb-4');
|
||||
content = content.replaceAll('text-lg font-bold text-foreground mb-1', 'text-2xl py-2 leading-relaxed font-bold text-foreground mb-1');
|
||||
content = content.replaceAll('text-xl font-bold text-foreground mb-4', 'text-3xl py-2 leading-relaxed font-bold text-foreground mb-2');
|
||||
content = content.replaceAll('text-2xl font-bold text-foreground mb-2', 'text-3xl py-2 leading-relaxed font-bold text-foreground mb-2');
|
||||
|
||||
// Footer H3/H4
|
||||
content = content.replaceAll('text-2xl font-bold mb-3', 'text-4xl py-2 leading-relaxed font-bold mb-2');
|
||||
content = content.replaceAll('text-lg font-semibold mb-3', 'text-2xl py-2 leading-relaxed font-semibold mb-2');
|
||||
|
||||
// Navbar
|
||||
content = content.replaceAll('text-3xl font-display font-medium', 'text-4xl py-2 leading-relaxed font-display font-medium');
|
||||
|
||||
if (original !== content) {
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(`Updated sizes in ${file}`);
|
||||
}
|
||||
}
|
||||
23
update_sizes_2.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dir = 'src/components';
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.tsx'));
|
||||
|
||||
for (const file of files) {
|
||||
if (file === 'HeroSection.tsx') continue;
|
||||
|
||||
const filePath = path.join(dir, file);
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let original = content;
|
||||
|
||||
content = content.replaceAll('text-5xl md:text-6xl font-bold py-2 leading-[1.3] pb-4', 'text-6xl md:text-7xl font-bold pt-4 pb-6 leading-normal');
|
||||
content = content.replaceAll('text-3xl py-2 leading-relaxed', 'text-4xl pt-3 pb-5 leading-[1.6]');
|
||||
content = content.replaceAll('text-2xl py-2 leading-relaxed', 'text-3xl pt-3 pb-5 leading-[1.6]');
|
||||
content = content.replaceAll('text-4xl py-2 leading-relaxed', 'text-5xl pt-3 pb-6 leading-[1.6]');
|
||||
|
||||
if (original !== content) {
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(`Updated sizes in ${file}`);
|
||||
}
|
||||
}
|
||||