mirror of
https://github.com/Ichitux/lambada-fiesta-live.git
synced 2026-05-15 14:32:19 +02:00
Added multi-lingua, english-spanish. Timer conditions and Linting bugfixes.
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 29s
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 29s
This commit is contained in:
25
.github/copilot-instructions.md
vendored
Normal file
25
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# RTK — Token-Optimized CLI
|
||||
|
||||
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
|
||||
|
||||
## Rule
|
||||
|
||||
Always prefix shell commands with `rtk`:
|
||||
|
||||
```bash
|
||||
# Instead of: Use:
|
||||
git status rtk git status
|
||||
git log -10 rtk git log -10
|
||||
cargo test rtk cargo test
|
||||
docker ps rtk docker ps
|
||||
kubectl get pods rtk kubectl pods
|
||||
```
|
||||
|
||||
## Meta commands (use directly)
|
||||
|
||||
```bash
|
||||
rtk gain # Token savings dashboard
|
||||
rtk gain --history # Per-command savings history
|
||||
rtk discover # Find missed rtk opportunities
|
||||
rtk proxy <cmd> # Run raw (no filtering) but track usage
|
||||
```
|
||||
12
.github/hooks/rtk-rewrite.json
vendored
Normal file
12
.github/hooks/rtk-rewrite.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "rtk hook copilot",
|
||||
"cwd": ".",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
16
bun.lock
16
bun.lock
@@ -34,6 +34,8 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"baseline-browser-mapping": "^2.10.11",
|
||||
"caniuse-lite": "^1.0.30001781",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -494,6 +496,8 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
@@ -510,7 +514,7 @@
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
@@ -1122,6 +1126,10 @@
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"autoprefixer/caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="],
|
||||
@@ -1148,8 +1156,6 @@
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
@@ -1166,8 +1172,6 @@
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
@@ -1215,7 +1219,5 @@
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
113
package-lock.json
generated
113
package-lock.json
generated
@@ -37,12 +37,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",
|
||||
@@ -50,6 +53,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",
|
||||
@@ -130,9 +134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
|
||||
"integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -3680,6 +3684,18 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz",
|
||||
"integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -3792,10 +3808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001727",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
||||
"dev": true,
|
||||
"version": "1.0.30001781",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
|
||||
"integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5183,6 +5198,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
@@ -5212,6 +5236,37 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.10.10",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz",
|
||||
"integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -6782,6 +6837,33 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.6.6",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz",
|
||||
"integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.10.9",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -7661,7 +7743,7 @@
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -7808,9 +7890,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -8013,6 +8095,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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="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: 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">
|
||||
{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;
|
||||
|
||||
@@ -4,6 +4,9 @@ import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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;
|
||||
@@ -151,6 +179,16 @@ 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
|
||||
@@ -166,10 +204,35 @@ const BookingSection = () => {
|
||||
>
|
||||
<CheckCircle 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">
|
||||
¡Reserva recibida!
|
||||
{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>
|
||||
@@ -191,11 +254,9 @@ const BookingSection = () => {
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<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">
|
||||
Reserva tu pase
|
||||
{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
|
||||
@@ -211,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>
|
||||
@@ -238,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
|
||||
@@ -263,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">
|
||||
@@ -290,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>
|
||||
@@ -302,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
|
||||
@@ -314,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>
|
||||
@@ -328,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"
|
||||
@@ -338,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 ${
|
||||
@@ -367,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 ${
|
||||
@@ -391,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>
|
||||
)}
|
||||
@@ -401,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>
|
||||
)}
|
||||
|
||||
@@ -413,9 +492,11 @@ const BookingSection = () => {
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</>
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> {t("booking.status.loading")}
|
||||
</>
|
||||
) : (
|
||||
"Reservar Pass"
|
||||
t("booking.buyButton")
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
|
||||
@@ -2,10 +2,12 @@ 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";
|
||||
|
||||
/** 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);
|
||||
@@ -42,13 +44,13 @@ 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-gray-500 text-white p-2 rounded-full hover:bg-gray-600 transition-colors shadow"
|
||||
aria-label="Volver arriba"
|
||||
aria-label={t("common.toTop")}
|
||||
>
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -1,72 +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 = () => (
|
||||
<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>
|
||||
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-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">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-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">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>
|
||||
</motion.footer>
|
||||
);
|
||||
</motion.footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSection;
|
||||
|
||||
@@ -1,53 +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 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">
|
||||
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: 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>
|
||||
))}
|
||||
return (
|
||||
<section
|
||||
id="gallery"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 100px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("gallery.title")}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-5xl mx-auto">
|
||||
{GALLERY_IMAGES.map((img, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="aspect-square rounded-xl overflow-hidden bg-muted flex items-center justify-center"
|
||||
>
|
||||
{img.src ? (
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground/40">
|
||||
<ImageIcon className="w-10 h-10 mx-auto mb-2" />
|
||||
<p className="text-xs">{img.alt}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EVENT_INFO } 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,10 +29,10 @@ 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 (
|
||||
@@ -53,7 +55,7 @@ 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
|
||||
@@ -62,7 +64,7 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.4 }}
|
||||
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
|
||||
@@ -71,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 */}
|
||||
@@ -81,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]">
|
||||
{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">
|
||||
{String(item.value).padStart(2, "0")}
|
||||
{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
|
||||
@@ -101,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="#booking">{t('hero.bookYourPass')}</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -2,64 +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 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">
|
||||
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: 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={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-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] 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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X } from "lucide-react";
|
||||
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);
|
||||
@@ -49,9 +56,21 @@ const Navbar = () => {
|
||||
href={link.href}
|
||||
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 */}
|
||||
@@ -85,9 +104,22 @@ const Navbar = () => {
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="text-3xl md:text-4xl lg:text-5xl pt-3 pb-6 break-words leading-[1.6] font-display font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
{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-3xl md:text-4xl lg:text-5xl pt-3 pb-6 font-display font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Globe className="w-8 h-8 md:w-10 md:h-10" />
|
||||
{i18n.language === 'es' ? 'EN' : 'ES'}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -2,30 +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 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 = () => (
|
||||
<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">
|
||||
{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 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">
|
||||
@@ -63,7 +66,7 @@ const OrgSection = () => (
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Imagen */}
|
||||
<motion.div
|
||||
@@ -78,9 +81,10 @@ const OrgSection = () => (
|
||||
className="rounded-2xl shadow-elevated w-full h-auto object-contain bg-muted"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrgSection;
|
||||
|
||||
@@ -1,93 +1,103 @@
|
||||
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 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, 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-6 leading-normal 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 }}
|
||||
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: false, amount: 0.15 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Aeropuertos */}
|
||||
<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">
|
||||
<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-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" /> 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PracticalSection;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { SCHEDULE } from "@/data/event-data";
|
||||
import { useState, useEffect } from "react";
|
||||
import { SCHEDULE, EVENT_INFO } from "@/data/event-data";
|
||||
import { Clock, Music, Coffee, Star } from "lucide-react";
|
||||
import { getTimeLeft } from "@/components/HeroSection";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const typeIcon: Record<string, typeof Clock> = {
|
||||
workshop: Clock,
|
||||
@@ -16,63 +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 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">
|
||||
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: false, amount: 0.15 }}
|
||||
transition={{ delay: di * 0.15 }}
|
||||
>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-4 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;
|
||||
|
||||
@@ -2,6 +2,7 @@ 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> = {
|
||||
@@ -11,12 +12,13 @@ const roleBadgeClass: Record<string, string> = {
|
||||
};
|
||||
|
||||
const StaffSection = () => {
|
||||
const [filter, setFilter] = useState<string>("All");
|
||||
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";
|
||||
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
|
||||
@@ -50,10 +52,10 @@ const StaffSection = () => {
|
||||
>
|
||||
<div className="text-center md:text-left w-full">
|
||||
<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">
|
||||
Staff del Evento
|
||||
{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>
|
||||
|
||||
@@ -83,17 +85,23 @@ const StaffSection = () => {
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="flex flex-wrap gap-3 mb-8 justify-center md:justify-start"
|
||||
>
|
||||
{["All", "Instructors", "DJs"].map((role) => (
|
||||
{(
|
||||
[
|
||||
{ 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={role}
|
||||
onClick={() => setFilter(role)}
|
||||
key={id}
|
||||
onClick={() => setFilter(id)}
|
||||
className={`px-6 py-2 rounded-full text-sm font-semibold transition-all duration-300 border ${
|
||||
filter === role
|
||||
filter === id
|
||||
? "bg-primary text-primary-foreground border-primary shadow-md"
|
||||
: "bg-card text-foreground border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{role === "All" ? "Todos" : role}
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -140,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-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 */}
|
||||
@@ -159,7 +179,7 @@ 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>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -24,18 +26,12 @@ import djcathie from "@/assets/staff/djcathie.jpg";
|
||||
|
||||
// ---- INFORMACIÓN GENERAL DEL EVENTO ----
|
||||
export const EVENT_INFO = {
|
||||
name: "ZoukLambadaBCN Beach Festival",
|
||||
subtitle: "Edición 2026",
|
||||
/** 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]",
|
||||
/** 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,
|
||||
};
|
||||
|
||||
// ---- WEBHOOK N8N ----
|
||||
@@ -65,32 +61,7 @@ export const WEBHOOK_URL = "https://n8n.hacecalor.net/webhook/event-reservation"
|
||||
*/
|
||||
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: `Este año 2026 se celebra el 18º Beach Festival de ZoukLambada, donde profesores, artistas y DJs de todo el mundo se reúnen para compartir su pasión por el baile en un ambiente único y especial.
|
||||
Compartimos nuestro amor por la lambada, nuestra raíz, nacida en la década de 1980 en Brasil. Con los años ha ido evolucionando sus movimientos fluídos siguiendo la musicalidad
|
||||
de esta bonita danza y creando diferentes estilos.`,
|
||||
philosophy: `Durante el evento Beach Festival ZoukLambada Bcn podrás disfrutar de diferentes estilos, sentirás la evolución de esta danza y la mágia que desprende.
|
||||
De viernes a domingo podrás disfrutar de workshops, parties, shows y mucho más.
|
||||
Sin olvidarnos del lunes y su fiesta de blanco.
|
||||
Puedes ver el programa completo en la sección "Programa" y reservar tu pase en la sección "Reservar", así como reservar tu habitaciń en la sección "Alojamiento", ¡¡no te quedes sin!!`,
|
||||
socials: {
|
||||
instagram: "https://instagram.com/zouklambadabcn",
|
||||
facebook: "https://facebook.com/zouklambadabcn",
|
||||
@@ -305,17 +276,11 @@ export const GALLERY_IMAGES = [
|
||||
|
||||
// ---- 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" },
|
||||
{ href: "#about" },
|
||||
{ href: "#staff" },
|
||||
{ href: "#schedule" },
|
||||
{ href: "#booking" },
|
||||
{ href: "#hotel" },
|
||||
{ href: "#info" },
|
||||
{ href: "#gallery" },
|
||||
];
|
||||
|
||||
// ---- FOOTER ----
|
||||
export const FOOTER = {
|
||||
email: "[email@zouklambadabcn.com]",
|
||||
copyright: `© ${new Date().getFullYear()} ZoukLambadaBCN. Todos los derechos reservados.`,
|
||||
};
|
||||
|
||||
23
src/i18n.ts
Normal file
23
src/i18n.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import es from './locales/es.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
const resources = {
|
||||
es: { translation: es },
|
||||
en: { translation: en },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
lng: 'es', // Default language
|
||||
fallbackLng: 'es',
|
||||
interpolation: {
|
||||
escapeValue: false, // React already handles escaping
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
156
src/locales/en.json
Normal file
156
src/locales/en.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"nav": {
|
||||
"about": "About",
|
||||
"staff": "Staff",
|
||||
"schedule": "Schedule",
|
||||
"booking": "Book your pass",
|
||||
"hotel": "Hotel",
|
||||
"info": "Info",
|
||||
"gallery": "Gallery"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Zouk Lambada Barcelona",
|
||||
"subtitle": "International Dance Festival",
|
||||
"dates": "June 20-22, 2025",
|
||||
"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, June 20",
|
||||
"saturday": "Saturday, June 21",
|
||||
"sunday": "Sunday, June 22",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
156
src/locales/es.json
Normal file
156
src/locales/es.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"nav": {
|
||||
"about": "Sobre",
|
||||
"staff": "Staff",
|
||||
"schedule": "Programa",
|
||||
"booking": "Reservar",
|
||||
"hotel": "Hotel",
|
||||
"info": "Info",
|
||||
"gallery": "Galería"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Zouk Lambada Barcelona",
|
||||
"subtitle": "Festival Internacional de Baile",
|
||||
"dates": "20-22 Junio 2025",
|
||||
"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 20 Junio",
|
||||
"saturday": "Sábado 21 Junio",
|
||||
"sunday": "Domingo 22 Junio",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galería"
|
||||
},
|
||||
"common": {
|
||||
"toTop": "Volver arriba"
|
||||
},
|
||||
"footer": {
|
||||
"title": "ZoukLambadaBCN",
|
||||
"contact": "Contacto",
|
||||
"followUs": "Síguenos",
|
||||
"email": "[email@zouklambadabcn.com]",
|
||||
"copyright": "© {{year}} ZoukLambadaBCN. Todos los derechos reservados."
|
||||
},
|
||||
"language": {
|
||||
"es": "ES",
|
||||
"en": "EN"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./i18n.ts";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
Reference in New Issue
Block a user