Compare commits

...

26 Commits

Author SHA1 Message Date
Ichitux
a8a0336ae5 Gallery + Info improvements
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m22s
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 19:02:59 +02:00
Ichitux
fce84ff4a6 Changes in module "mixed reservations"
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m23s
2026-04-24 01:59:50 +02:00
Ichitux
823ca68119 Newer section and modular view
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m14s
2026-04-14 15:48:44 +02:00
Ichitux
7a65e7a1f4 Minor fixes, texts
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m6s
2026-04-01 01:15:52 +02:00
Antoni Nuñez Romeu
cd9e914713 Readme
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m4s
2026-03-30 11:03:27 +02:00
Antoni Nuñez Romeu
18cc2e66af Remove nodejs version older than 22 2026-03-30 10:59:25 +02:00
Antoni Nuñez Romeu
2d216b907e npm install
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 2m15s
2026-03-30 10:42:01 +02:00
Antoni Nuñez Romeu
a97e4f4469 Fixed building issue 2026-03-30 10:39:49 +02:00
Ichitux
f53e271553 refactor: adjust Navbar responsive styling, update Vite host configuration, and upgrade jsdom and vite dependencies.
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m2s
2026-03-28 01:52:05 +01:00
Ichitux
c30c2640ec Update node.js.yml
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m3s
2026-03-27 17:50:22 +01:00
Ichitux
a036e78897 Update README.md 2026-03-27 17:46:37 +01:00
Ichitux
632d2c6086 Update README.md 2026-03-27 17:46:10 +01:00
Ichitux
b6dd07459e Update node.js.yml 2026-03-27 17:45:38 +01:00
Antoni Nuñez Romeu
42c64e9f97 Rollback file 2026-03-27 17:23:05 +01:00
Antoni Nuñez Romeu
826ed1ce07 Fixes in deployments & ci/cd, readme info
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m3s
2026-03-27 17:15:11 +01:00
Antoni Nuñez Romeu
935921f698 Added multi-lingua, english-spanish. Timer conditions and Linting bugfixes.
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 29s
2026-03-27 16:23:06 +01:00
Antoni Nuñez Romeu
5b663be89f Mail & Fixes
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 52s
2026-03-20 02:57:46 +01:00
Antoni Nuñez Romeu
b87443f0e5 More staff pictures & styling
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 52s
2026-03-20 02:12:04 +01:00
Antoni Nuñez Romeu
16cb8c78ce Hotfixes from Trello, size, boxes, text, etc.
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 56s
2026-03-20 01:05:49 +01:00
Antoni Nuñez Romeu
445e1570b4 Font changes & adjustments in size, remove jenkins file & less tasks to runners.
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 53s
2026-03-19 18:16:44 +01:00
Antoni Nuñez Romeu
ba4e76058e Favicon + grow logo size
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 1m4s
2026-03-19 17:51:11 +01:00
Antoni Nuñez Romeu
c9d4621aaf Hotfixing gitea ci/cd
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 3m50s
2026-03-19 17:39:58 +01:00
Antoni Nuñez Romeu
7b0164dfc6 Changes & Menus, enabled Gitea Workflow
Some checks failed
Deploy NPM app / Explore-Gitea-Actions (push) Has been cancelled
2026-03-19 17:29:05 +01:00
Antoni Nuñez Romeu
3c1ae1643b Adding animations 2026-03-19 16:48:42 +01:00
Antoni Nuñez Romeu
3f0618829f Warmer style for the entire website + shapes 2026-03-19 16:27:03 +01:00
Antoni Nuñez Romeu
71b2c5f2ed Modified jenkinsfile 2026-03-19 11:45:55 +01:00
46 changed files with 2918 additions and 2551 deletions

View 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
View 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
View File

@@ -0,0 +1,12 @@
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "rtk hook copilot",
"cwd": ".",
"timeout": 5
}
]
}
}

View File

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

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

View File

@@ -1,4 +1,26 @@
Follow these steps:
<!-- README.md -->
+ [![Node.js CI](https://github.com/Ichitux/lambada-fiesta-live/actions/workflows/node.js.yml/badge.svg)](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
```

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 27 KiB

40
scale_fix.mjs Normal file
View 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}`);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

BIN
src/assets/gallery/gal1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

BIN
src/assets/gallery/gal2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

BIN
src/assets/gallery/gal3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

BIN
src/assets/gallery/gal4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
src/assets/gallery/gal5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

BIN
src/assets/gallery/gal6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

@@ -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>
);
};

View File

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

View File

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

View File

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

View File

@@ -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) */}

View File

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

View File

@@ -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
View 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
View 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"
}
}

View File

@@ -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 />);

View File

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

View File

@@ -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
View 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
View 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
View 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
View 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}`);
}
}