Added multi-lingua, english-spanish. Timer conditions and Linting bugfixes.
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 29s

This commit is contained in:
Antoni Nuñez Romeu
2026-03-27 16:23:06 +01:00
parent 5b663be89f
commit 935921f698
22 changed files with 1220 additions and 509 deletions

25
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,25 @@
# RTK — Token-Optimized CLI
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
## Rule
Always prefix shell commands with `rtk`:
```bash
# Instead of: Use:
git status rtk git status
git log -10 rtk git log -10
cargo test rtk cargo test
docker ps rtk docker ps
kubectl get pods rtk kubectl pods
```
## Meta commands (use directly)
```bash
rtk gain # Token savings dashboard
rtk gain --history # Per-command savings history
rtk discover # Find missed rtk opportunities
rtk proxy <cmd> # Run raw (no filtering) but track usage
```

12
.github/hooks/rtk-rewrite.json vendored Normal file
View File

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

View File

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

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

View File

@@ -42,12 +42,15 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"baseline-browser-mapping": "^2.10.11",
"caniuse-lite": "^1.0.30001781",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.35.0",
"i18next": "^25.10.10",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
@@ -55,6 +58,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-i18next": "^16.6.6",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",

View File

@@ -1,63 +1,68 @@
import { motion } from "framer-motion";
import { ABOUT_EVENT } from "@/data/event-data";
import aboutImg from "@/assets/about-event.jpg";
import { Music, Users, Sparkles, PartyPopper } from "lucide-react";
import { useTranslation } from "react-i18next";
const iconMap = [Music, Users, Sparkles, PartyPopper];
const AboutSection = () => (
<section id="about" className="section-padding bg-background 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import { motion } from "framer-motion";
import { SCHEDULE } from "@/data/event-data";
import { useState, useEffect } from "react";
import { SCHEDULE, EVENT_INFO } from "@/data/event-data";
import { Clock, Music, Coffee, Star } from "lucide-react";
import { getTimeLeft } from "@/components/HeroSection";
import { useTranslation } from "react-i18next";
const typeIcon: Record<string, typeof Clock> = {
workshop: Clock,
@@ -16,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;

View File

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

View File

@@ -3,9 +3,11 @@
* DATOS DEL EVENTO — ZoukLambadaBCN
* ===========================================
*
* Este archivo centraliza TODOS los datos editables del evento.
* Para modificar cualquier información, simplemente edita las
* constantes de este archivo.
* Este archivo centraliza datos "no traducibles" del evento:
* fechas, URLs, embeds, imágenes y arrays estructurados.
*
* El texto visible (títulos, descripciones, labels) vive en:
* `src/locales/*.json` y se consume vía i18n.
*
* NOTA: Las imágenes deben colocarse en src/assets/ e importarse.
*/
@@ -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
View File

@@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import es from './locales/es.json';
import en from './locales/en.json';
const resources = {
es: { translation: es },
en: { translation: en },
};
i18n
.use(initReactI18next)
.init({
resources,
lng: 'es', // Default language
fallbackLng: 'es',
interpolation: {
escapeValue: false, // React already handles escaping
},
});
export default i18n;

156
src/locales/en.json Normal file
View 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
View 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"
}
}

View File

@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./i18n.ts";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);