This commit is contained in:
gpt-engineer-app[bot]
2026-03-05 15:50:22 +00:00
parent b331aa1a7d
commit a11683b7dc
23 changed files with 2958 additions and 96 deletions

BIN
src/assets/about-event.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
src/assets/community.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
src/assets/hero-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

View File

@@ -0,0 +1,63 @@
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";
const iconMap = [Music, Users, Sparkles, PartyPopper];
const AboutSection = () => (
<section id="about" className="section-padding bg-background">
<div className="container mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
{/* Imagen */}
<motion.div
initial={{ opacity: 0, x: -40 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<img
src={aboutImg}
alt="Evento de Lambada"
className="rounded-2xl shadow-elevated w-full object-cover aspect-square"
/>
</motion.div>
{/* Texto */}
<motion.div
initial={{ opacity: 0, x: 40 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h2 className="font-display text-4xl md:text-5xl font-bold mb-6 text-gradient">
{ABOUT_EVENT.title}
</h2>
<p className="text-muted-foreground mb-6 leading-relaxed whitespace-pre-line">
{ABOUT_EVENT.description}
</p>
<p className="text-foreground/90 mb-8 leading-relaxed italic border-l-4 border-primary pl-4">
{ABOUT_EVENT.lambadaInfo}
</p>
{/* 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" />
</div>
<span className="text-sm font-medium text-foreground">{item}</span>
</div>
);
})}
</div>
</motion.div>
</div>
</div>
</section>
);
export default AboutSection;

View File

@@ -0,0 +1,211 @@
import { useState } from "react";
import { motion } from "framer-motion";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { WEBHOOK_URL } from "@/data/event-data";
import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
/**
* FORMULARIO DE RESERVA
*
* Envía un POST al webhook de n8n con los datos del formulario.
* Para configurar:
* 1. En n8n, crea un workflow con nodo "Webhook" (POST)
* 2. Pega la URL generada en WEBHOOK_URL (event-data.ts)
* 3. Conecta el webhook a Google Sheets / Airtable / Email
*/
const bookingSchema = z.object({
name: z.string().trim().min(2, "El nombre debe tener al menos 2 caracteres").max(100),
email: z.string().trim().email("Email no válido").max(255),
seats: z.string().min(1, "Selecciona el número de plazas"),
country: z.string().trim().min(2, "Indica tu país").max(100),
comment: z.string().max(500).optional(),
});
type BookingData = z.infer<typeof bookingSchema>;
const BookingSection = () => {
const [form, setForm] = useState<BookingData>({
name: "", email: "", seats: "1", country: "", comment: "",
});
const [errors, setErrors] = useState<Partial<Record<keyof BookingData, string>>>({});
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => ({ ...prev, [name]: undefined }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
const result = bookingSchema.safeParse(form);
if (!result.success) {
const fieldErrors: typeof errors = {};
result.error.errors.forEach((err) => {
const field = err.path[0] as keyof BookingData;
fieldErrors[field] = err.message;
});
setErrors(fieldErrors);
return;
}
setStatus("loading");
try {
const res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result.data),
});
if (!res.ok) throw new Error("Error en la solicitud");
setStatus("success");
} catch {
setStatus("error");
}
};
if (status === "success") {
return (
<section id="booking" className="section-padding bg-background">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-lg mx-auto text-center bg-card rounded-2xl p-10 shadow-elevated"
>
<CheckCircle className="w-16 h-16 text-primary mx-auto mb-4" />
<h3 className="font-display text-2xl font-bold text-foreground mb-2">
¡Reserva recibida!
</h3>
<p className="text-muted-foreground">
Recibirás confirmación por email. ¡Nos vemos en la pista!
</p>
</motion.div>
</section>
);
}
return (
<section id="booking" className="section-padding bg-background">
<div className="container mx-auto max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-10"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
Reservar Asiento
</h2>
<p className="text-muted-foreground">
Sin pago online. Solo reserva tu plaza y paga en el evento.
</p>
</motion.div>
<motion.form
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
onSubmit={handleSubmit}
className="bg-card rounded-2xl p-6 md:p-10 shadow-elevated space-y-5"
>
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Nombre *</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 completo"
/>
{errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">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"
/>
{errors.email && <p className="text-destructive text-xs mt-1">{errors.email}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Plazas */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Plazas *</label>
<select
name="seats"
value={form.seats}
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"
>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={String(n)}>{n}</option>
))}
</select>
{errors.seats && <p className="text-destructive text-xs mt-1">{errors.seats}</p>}
</div>
{/* País */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">País *</label>
<input
name="country"
value={form.country}
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="España"
/>
{errors.country && <p className="text-destructive text-xs mt-1">{errors.country}</p>}
</div>
</div>
{/* Comentarios */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Comentarios</label>
<textarea
name="comment"
value={form.comment}
onChange={handleChange}
rows={3}
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 resize-none"
placeholder="¿Algo que quieras comentarnos?"
/>
</div>
{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.
</div>
)}
<Button
type="submit"
variant="hero"
size="lg"
className="w-full text-base py-6"
disabled={status === "loading"}
>
{status === "loading" ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</>
) : (
"Reservar Asiento"
)}
</Button>
</motion.form>
</div>
</section>
);
};
export default BookingSection;

View File

@@ -0,0 +1,46 @@
import { Button } from "@/components/ui/button";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useEffect } from "react";
import { ChevronUp } from "lucide-react";
/** Botón flotante de reserva + scroll to top */
const FloatingButton = () => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 400);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="fixed bottom-6 right-6 z-50 flex flex-col gap-2"
>
<Button
variant="hero"
size="lg"
className="animate-pulse-glow rounded-full px-6 shadow-elevated"
asChild
>
<a href="#booking">🎶 Reservar</a>
</Button>
<button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
className="self-center bg-foreground/20 backdrop-blur-sm p-2 rounded-full hover:bg-foreground/30 transition-colors"
aria-label="Volver arriba"
>
<ChevronUp className="w-4 h-4 text-foreground" />
</button>
</motion.div>
)}
</AnimatePresence>
);
};
export default FloatingButton;

View File

@@ -0,0 +1,61 @@
import { ABOUT_ORG, FOOTER } from "@/data/event-data";
import { Instagram, Facebook, Youtube, Mail } from "lucide-react";
const FooterSection = () => (
<footer className="bg-foreground text-primary-foreground">
<div className="container mx-auto px-4 py-12">
<div className="grid md:grid-cols-3 gap-8 mb-8">
{/* Brand */}
<div>
<h3 className="font-display text-2xl font-bold mb-3">ZoukLambadaBCN</h3>
<p className="text-primary-foreground/70 text-sm">
Comunidad de baile en Barcelona.
</p>
</div>
{/* Contacto */}
<div>
<h4 className="font-display text-lg font-semibold mb-3">Contacto</h4>
<a
href={`mailto:${FOOTER.email}`}
className="flex items-center gap-2 text-sm text-primary-foreground/70 hover:text-primary transition-colors"
>
<Mail className="w-4 h-4" />
{FOOTER.email}
</a>
</div>
{/* Redes */}
<div>
<h4 className="font-display text-lg font-semibold mb-3">Síguenos</h4>
<div className="flex gap-3">
{ABOUT_ORG.socials.instagram && (
<a href={ABOUT_ORG.socials.instagram} target="_blank" rel="noopener noreferrer" aria-label="Instagram"
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
<Instagram className="w-5 h-5" />
</a>
)}
{ABOUT_ORG.socials.facebook && (
<a href={ABOUT_ORG.socials.facebook} target="_blank" rel="noopener noreferrer" aria-label="Facebook"
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
<Facebook className="w-5 h-5" />
</a>
)}
{ABOUT_ORG.socials.youtube && (
<a href={ABOUT_ORG.socials.youtube} target="_blank" rel="noopener noreferrer" aria-label="YouTube"
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
<Youtube className="w-5 h-5" />
</a>
)}
</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>
</div>
</footer>
);
export default FooterSection;

View File

@@ -0,0 +1,49 @@
import { motion } from "framer-motion";
import { GALLERY_IMAGES } from "@/data/event-data";
import { ImageIcon } from "lucide-react";
const GallerySection = () => (
<section id="gallery" className="section-padding bg-card">
<div className="container mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
Galería
</h2>
</motion.div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-5xl mx-auto">
{GALLERY_IMAGES.map((img, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="aspect-square rounded-xl overflow-hidden bg-muted flex items-center justify-center"
>
{img.src ? (
<img
src={img.src}
alt={img.alt}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
) : (
<div className="text-center text-muted-foreground/40">
<ImageIcon className="w-10 h-10 mx-auto mb-2" />
<p className="text-xs">{img.alt}</p>
</div>
)}
</motion.div>
))}
</div>
</div>
</section>
);
export default GallerySection;

View File

@@ -0,0 +1,109 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
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) => {
const diff = new Date(target).getTime() - Date.now();
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
return {
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
minutes: Math.floor((diff / (1000 * 60)) % 60),
seconds: Math.floor((diff / 1000) % 60),
};
};
const HeroSection = () => {
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(getTimeLeft(EVENT_INFO.date));
}, 1000);
return () => clearInterval(timer);
}, []);
const countdownItems = [
{ value: timeLeft.days, label: "Días" },
{ value: timeLeft.hours, label: "Horas" },
{ value: timeLeft.minutes, label: "Min" },
{ value: timeLeft.seconds, label: "Seg" },
];
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
{/* Background image */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${heroBg})` }}
/>
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-b from-foreground/70 via-foreground/50 to-foreground/80" />
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-primary font-body text-sm uppercase tracking-[0.3em] mb-4"
>
{EVENT_INFO.dateDisplay} · {EVENT_INFO.city}
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="font-display text-5xl md:text-7xl lg:text-8xl font-bold text-primary-foreground mb-4 leading-tight"
>
{EVENT_INFO.name}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="text-primary-foreground/80 font-body text-lg md:text-xl mb-10"
>
{EVENT_INFO.subtitle}
</motion.p>
{/* Countdown */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.8 }}
className="flex justify-center gap-4 md:gap-8 mb-10"
>
{countdownItems.map((item) => (
<div key={item.label} className="text-center">
<div className="bg-primary-foreground/10 backdrop-blur-sm border border-primary-foreground/20 rounded-lg px-4 py-3 md:px-6 md:py-4 min-w-[60px] md:min-w-[80px]">
<span className="text-2xl md:text-4xl font-display font-bold text-primary-foreground">
{String(item.value).padStart(2, "0")}
</span>
</div>
<p className="text-primary-foreground/60 text-xs mt-2 uppercase tracking-wider">
{item.label}
</p>
</div>
))}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1 }}
>
<Button variant="hero" size="lg" className="text-lg px-10 py-6" asChild>
<a href="#booking">Reservar Asiento</a>
</Button>
</motion.div>
</div>
</section>
);
};
export default HeroSection;

View File

@@ -0,0 +1,61 @@
import { motion } from "framer-motion";
import { HOTEL_ROOMS } from "@/data/event-data";
import { Button } from "@/components/ui/button";
import { BedDouble, ExternalLink } from "lucide-react";
const HotelSection = () => (
<section id="hotel" className="section-padding bg-card">
<div className="container mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
Alojamiento
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Habitaciones recomendadas cerca del venue.
</p>
</motion.div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
{HOTEL_ROOMS.map((room, i) => (
<motion.div
key={room.id}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="bg-background rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow"
>
{/* Imagen */}
<div className="aspect-video bg-muted flex items-center justify-center overflow-hidden">
{room.image ? (
<img src={room.image} alt={room.name} className="w-full h-full object-cover" />
) : (
<BedDouble className="w-12 h-12 text-muted-foreground/40" />
)}
</div>
<div className="p-5">
<h3 className="font-display text-lg font-bold text-foreground mb-1">
{room.name}
</h3>
<p className="text-primary font-bold text-xl mb-2">{room.price}</p>
<p className="text-sm text-muted-foreground mb-4">{room.description}</p>
<Button variant="outline" className="w-full" asChild>
<a href={room.link} target="_blank" rel="noopener noreferrer">
Reservar en el hotel <ExternalLink className="w-4 h-4 ml-1" />
</a>
</Button>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
export default HotelSection;

80
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Menu, X } from "lucide-react";
import { NAV_LINKS } from "@/data/event-data";
/**
* Navbar sticky con menú responsive.
* Los links se definen en event-data.ts
*/
const Navbar = () => {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 50);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? "bg-background/95 backdrop-blur-md shadow-card"
: "bg-transparent"
}`}
>
<div className="container mx-auto flex items-center justify-between px-4 py-3">
{/* Logo */}
<a href="#" className="font-display text-xl font-bold text-gradient">
ZoukLambadaBCN
</a>
{/* Desktop links */}
<div className="hidden md:flex items-center gap-6">
{NAV_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-foreground/80 hover:text-primary transition-colors"
>
{link.label}
</a>
))}
</div>
{/* Mobile toggle */}
<button
className="md:hidden text-foreground"
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Toggle menu"
>
{menuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{/* Mobile menu */}
{menuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="md:hidden bg-background/98 backdrop-blur-md border-t border-border px-4 pb-4"
>
{NAV_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
onClick={() => setMenuOpen(false)}
className="block py-3 text-sm font-medium text-foreground/80 hover:text-primary transition-colors border-b border-border/50"
>
{link.label}
</a>
))}
</motion.div>
)}
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,81 @@
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";
const OrgSection = () => (
<section className="section-padding bg-card">
<div className="container mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
{/* Texto */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h2 className="font-display text-4xl md:text-5xl font-bold mb-6 text-gradient">
{ABOUT_ORG.title}
</h2>
<div className="space-y-4 text-muted-foreground leading-relaxed">
<p className="whitespace-pre-line">{ABOUT_ORG.history}</p>
<p className="whitespace-pre-line">{ABOUT_ORG.philosophy}</p>
</div>
{/* Redes sociales */}
<div className="flex gap-4 mt-8">
{ABOUT_ORG.socials.instagram && (
<a
href={ABOUT_ORG.socials.instagram}
target="_blank"
rel="noopener noreferrer"
className="bg-gradient-tropical p-3 rounded-full hover:opacity-80 transition-opacity"
aria-label="Instagram"
>
<Instagram className="w-5 h-5 text-primary-foreground" />
</a>
)}
{ABOUT_ORG.socials.facebook && (
<a
href={ABOUT_ORG.socials.facebook}
target="_blank"
rel="noopener noreferrer"
className="bg-gradient-tropical p-3 rounded-full hover:opacity-80 transition-opacity"
aria-label="Facebook"
>
<Facebook className="w-5 h-5 text-primary-foreground" />
</a>
)}
{ABOUT_ORG.socials.youtube && (
<a
href={ABOUT_ORG.socials.youtube}
target="_blank"
rel="noopener noreferrer"
className="bg-gradient-tropical p-3 rounded-full hover:opacity-80 transition-opacity"
aria-label="YouTube"
>
<Youtube className="w-5 h-5 text-primary-foreground" />
</a>
)}
</div>
</motion.div>
{/* Imagen */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<img
src={communityImg}
alt="Comunidad ZoukLambadaBCN"
className="rounded-2xl shadow-elevated w-full object-cover aspect-square"
/>
</motion.div>
</div>
</div>
</section>
);
export default OrgSection;

View File

@@ -0,0 +1,89 @@
import { motion } from "framer-motion";
import { EVENT_INFO, PRACTICAL_INFO } from "@/data/event-data";
import { MapPin, Plane, Train } from "lucide-react";
const PracticalSection = () => (
<section id="info" className="section-padding bg-background">
<div className="container mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
Información Práctica
</h2>
</motion.div>
<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: true }}
>
<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" />
<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: true }}
className="space-y-8"
>
{/* Aeropuertos */}
<div>
<h3 className="font-display text-xl font-bold text-foreground mb-4 flex items-center gap-2">
<Plane className="w-5 h-5 text-primary" /> Aeropuertos Cercanos
</h3>
<div className="space-y-3">
{PRACTICAL_INFO.airports.map((a) => (
<div key={a.name} className="bg-card rounded-lg p-3">
<p className="font-medium text-foreground text-sm">{a.name}</p>
<p className="text-xs text-muted-foreground">{a.distance}</p>
</div>
))}
</div>
</div>
{/* Cómo llegar */}
<div>
<h3 className="font-display text-xl font-bold text-foreground mb-4 flex items-center gap-2">
<Train className="w-5 h-5 text-primary" /> Cómo Llegar
</h3>
<div className="space-y-3">
{PRACTICAL_INFO.howToGet.map((h) => (
<div key={h.method} className="bg-card rounded-lg p-3">
<p className="font-medium text-foreground text-sm">{h.method}</p>
<p className="text-xs text-muted-foreground">{h.details}</p>
</div>
))}
</div>
</div>
</motion.div>
</div>
</div>
</section>
);
export default PracticalSection;

View File

@@ -0,0 +1,74 @@
import { motion } from "framer-motion";
import { SCHEDULE } from "@/data/event-data";
import { Clock, Music, Coffee, Star } from "lucide-react";
const typeIcon: Record<string, typeof Clock> = {
workshop: Clock,
social: Music,
break: Coffee,
show: Star,
};
const typeColor: Record<string, string> = {
workshop: "border-primary bg-primary/10 text-primary",
social: "border-secondary bg-secondary/10 text-secondary",
break: "border-muted-foreground bg-muted text-muted-foreground",
show: "border-accent bg-accent/10 text-accent",
};
const ScheduleSection = () => (
<section id="schedule" className="section-padding bg-card">
<div className="container mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
Programa
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Tres días de workshops, shows y social dance.
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-8">
{SCHEDULE.map((day, di) => (
<motion.div
key={day.day}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: di * 0.15 }}
>
<h3 className="font-display text-xl font-bold text-foreground mb-6 pb-3 border-b-2 border-primary">
{day.day}
</h3>
<div className="space-y-3">
{day.events.map((event, ei) => {
const Icon = typeIcon[event.type] || Clock;
return (
<div
key={ei}
className={`flex items-start gap-3 p-3 rounded-lg border-l-4 ${
typeColor[event.type] || ""
}`}
>
<Icon className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-medium opacity-70">{event.time}</p>
<p className="text-sm font-semibold">{event.title}</p>
</div>
</div>
);
})}
</div>
</motion.div>
))}
</div>
</div>
</section>
);
export default ScheduleSection;

View File

@@ -0,0 +1,89 @@
import { motion } from "framer-motion";
import { STAFF } from "@/data/event-data";
import { Instagram, User } from "lucide-react";
/** Colores de badge por rol */
const roleBadgeClass: Record<string, string> = {
Instructor: "bg-primary text-primary-foreground",
DJ: "bg-secondary text-secondary-foreground",
Organizador: "bg-accent text-accent-foreground",
};
const StaffSection = () => (
<section id="staff" className="section-padding bg-background">
<div className="container mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
Staff del Evento
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Conoce a los artistas e instructores que harán de este festival una experiencia inolvidable.
</p>
</motion.div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{STAFF.map((member, i) => (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="bg-card rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow group"
>
{/* Foto */}
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
{member.image ? (
<img
src={member.image}
alt={member.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<User className="w-16 h-16 text-muted-foreground/40" />
)}
</div>
<div className="p-5">
{/* Badge de rol */}
<span
className={`inline-block text-xs font-semibold px-3 py-1 rounded-full mb-3 ${
roleBadgeClass[member.role] || "bg-muted text-muted-foreground"
}`}
>
{member.role}
</span>
<h3 className="font-display text-lg font-bold text-foreground mb-2">
{member.name}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{member.description}
</p>
{/* Redes sociales */}
{member.socials?.instagram && (
<a
href={member.socials.instagram}
target="_blank"
rel="noopener noreferrer"
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
</a>
)}
</div>
</motion.div>
))}
</div>
</div>
</section>
);
export default StaffSection;

View File

@@ -15,6 +15,8 @@ const buttonVariants = cva(
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
hero: "bg-gradient-tropical text-primary-foreground shadow-glow hover:opacity-90 font-semibold text-base",
tropical: "bg-secondary text-secondary-foreground hover:bg-secondary/90 shadow-glow font-semibold",
},
size: {
default: "h-10 px-4 py-2",

237
src/data/event-data.ts Normal file
View File

@@ -0,0 +1,237 @@
/**
* ===========================================
* 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.
*
* NOTA: Las imágenes deben colocarse en src/assets/ e importarse.
*/
// ---- INFORMACIÓN GENERAL DEL EVENTO ----
export const EVENT_INFO = {
name: "Lambada Festival Barcelona",
subtitle: "by ZoukLambadaBCN",
/** Fecha del evento — formato ISO para el countdown */
date: "2026-06-20T18:00:00",
/** Fecha legible para mostrar */
dateDisplay: "2022 Junio 2026",
city: "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 ----
/**
* CONFIGURACIÓN DEL WEBHOOK:
*
* 1. En n8n, crea un nuevo workflow con un nodo "Webhook"
* 2. Configura el webhook como POST
* 3. Copia la URL generada y pégala aquí abajo
* 4. En n8n, conecta el webhook a tu destino (Google Sheets, Airtable, etc.)
*
* Ejemplo de payload que se envía:
* {
* "name": "Juan García",
* "email": "juan@example.com",
* "seats": "2",
* "country": "España",
* "comment": "Primera vez en el festival"
* }
*/
export const WEBHOOK_URL = "https://YOUR-N8N-INSTANCE/webhook/event-reservation";
// ---- SOBRE EL EVENTO ----
export const ABOUT_EVENT = {
title: "Sobre el Evento",
description: `[Descripción del evento. Explica qué hace especial esta edición,
qué pueden esperar los asistentes, y por qué no se lo pueden perder.]`,
lambadaInfo: `La Lambada es un baile brasileño nacido en los años 80,
conocido por su sensualidad, conexión y ritmo envolvente.
Mezcla influencias de forró, merengue y carimbó,
creando una experiencia de baile única y apasionante.`,
highlights: [
"Workshops con artistas internacionales",
"Social dancing durante toda la noche",
"Shows en vivo",
"DJ sets tropicales",
],
};
// ---- SOBRE ZOUKLAMBADABCN ----
export const ABOUT_ORG = {
title: "ZoukLambadaBCN",
history: `[Historia del grupo organizador. Cuándo se fundó,
cómo empezó, qué han logrado hasta ahora.]`,
philosophy: `[Filosofía del grupo. Qué valores defienden,
qué quieren aportar a la comunidad de baile.]`,
socials: {
instagram: "https://instagram.com/zouklambadabcn",
facebook: "https://facebook.com/zouklambadabcn",
youtube: "https://youtube.com/@zouklambadabcn",
},
};
// ---- STAFF DEL EVENTO ----
/**
* Para añadir un nuevo miembro del staff,
* simplemente agrega un nuevo objeto al array.
*/
export const STAFF = [
{
id: "1",
name: "[Nombre Instructor 1]",
role: "Instructor" as const,
description: "[Breve biografía del instructor]",
/** Reemplazar con ruta a foto real */
image: "",
socials: {
instagram: "",
},
},
{
id: "2",
name: "[Nombre Instructor 2]",
role: "Instructor" as const,
description: "[Breve biografía del instructor]",
image: "",
socials: {
instagram: "",
},
},
{
id: "3",
name: "[Nombre DJ]",
role: "DJ" as const,
description: "[Breve biografía del DJ]",
image: "",
socials: {
instagram: "",
soundcloud: "",
},
},
{
id: "4",
name: "[Nombre Organizador]",
role: "Organizador" as const,
description: "[Breve biografía del organizador]",
image: "",
socials: {
instagram: "",
},
},
];
// ---- PROGRAMA DEL EVENTO ----
export const SCHEDULE = [
{
day: "Viernes 20 Junio",
events: [
{ time: "18:00 19:30", title: "[Workshop 1]", type: "workshop" as const },
{ time: "19:30 20:00", title: "Pausa", type: "break" as const },
{ time: "20:00 21:30", title: "[Workshop 2]", type: "workshop" as const },
{ time: "22:00 03:00", title: "Social Dance + DJ Set", type: "social" as const },
],
},
{
day: "Sábado 21 Junio",
events: [
{ time: "12:00 13:30", title: "[Workshop 3]", type: "workshop" as const },
{ time: "14:00 15:30", title: "[Workshop 4]", type: "workshop" as const },
{ time: "16:00 17:30", title: "[Workshop 5]", type: "workshop" as const },
{ time: "21:00 22:00", title: "Shows en Vivo", type: "show" as const },
{ time: "22:00 04:00", title: "Social Dance + DJ Sets", type: "social" as const },
],
},
{
day: "Domingo 22 Junio",
events: [
{ time: "12:00 13:30", title: "[Workshop 6]", type: "workshop" as const },
{ time: "14:00 15:30", title: "[Workshop 7]", type: "workshop" as const },
{ time: "17:00 22:00", title: "Farewell Party", type: "social" as const },
],
},
];
// ---- HABITACIONES DE HOTEL ----
/**
* Para añadir habitaciones, agrega objetos al array.
* El botón "Reservar" abre el link en nueva pestaña.
*/
export const HOTEL_ROOMS = [
{
id: "1",
name: "[Habitación Individual]",
price: "[XX€/noche]",
description: "[Descripción breve de la habitación]",
/** Reemplazar con URL de imagen real */
image: "",
link: "https://hotel-example.com/booking",
},
{
id: "2",
name: "[Habitación Doble]",
price: "[XX€/noche]",
description: "[Descripción breve de la habitación]",
image: "",
link: "https://hotel-example.com/booking",
},
{
id: "3",
name: "[Suite]",
price: "[XX€/noche]",
description: "[Descripción breve de la habitación]",
image: "",
link: "https://hotel-example.com/booking",
},
];
// ---- INFORMACIÓN PRÁCTICA ----
export const PRACTICAL_INFO = {
airports: [
{ name: "Aeropuerto de Barcelona-El Prat (BCN)", distance: "~15 km del venue" },
{ name: "Aeropuerto de Girona (GRO)", distance: "~100 km del venue" },
{ name: "Aeropuerto de Reus (REU)", distance: "~110 km del venue" },
],
howToGet: [
{ method: "Metro", details: "[Línea y parada más cercana]" },
{ method: "Bus", details: "[Líneas de bus cercanas]" },
{ method: "Taxi/Uber", details: "Disponible desde cualquier punto de Barcelona" },
],
};
// ---- GALERÍA ----
/**
* Añade URLs o importaciones de imágenes.
* Para importar: import img from "@/assets/gallery/photo1.jpg"
*/
export const GALLERY_IMAGES = [
{ src: "", alt: "[Descripción foto 1]" },
{ src: "", alt: "[Descripción foto 2]" },
{ src: "", alt: "[Descripción foto 3]" },
{ src: "", alt: "[Descripción foto 4]" },
{ src: "", alt: "[Descripción foto 5]" },
{ src: "", alt: "[Descripción foto 6]" },
];
// ---- NAVEGACIÓN ----
export const NAV_LINKS = [
{ label: "Sobre", href: "#about" },
{ label: "Staff", href: "#staff" },
{ label: "Programa", href: "#schedule" },
{ label: "Reservar", href: "#booking" },
{ label: "Hotel", href: "#hotel" },
{ label: "Info", href: "#info" },
{ label: "Galería", href: "#gallery" },
];
// ---- FOOTER ----
export const FOOTER = {
email: "[email@zouklambadabcn.com]",
copyright: `© ${new Date().getFullYear()} ZoukLambadaBCN. Todos los derechos reservados.`,
};

View File

@@ -1,88 +1,89 @@
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
/* ===========================================
DESIGN SYSTEM — ZoukLambadaBCN Event
Tropical palette: orange, pink, violet
All colors in HSL
=========================================== */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Tropical warm palette */
--background: 30 30% 97%;
--foreground: 280 30% 12%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card: 30 25% 95%;
--card-foreground: 280 30% 12%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 280 30% 12%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* Primary: vibrant orange */
--primary: 24 95% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
/* Secondary: tropical pink */
--secondary: 340 82% 60%;
--secondary-foreground: 0 0% 100%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
/* Accent: rich violet */
--accent: 280 65% 50%;
--accent-foreground: 0 0% 100%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--muted: 30 20% 92%;
--muted-foreground: 280 15% 40%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: 30 20% 85%;
--input: 30 20% 85%;
--ring: 24 95% 55%;
--radius: 0.5rem;
--radius: 0.75rem;
/* Custom tokens */
--gradient-tropical: linear-gradient(135deg, hsl(24 95% 55%), hsl(340 82% 60%), hsl(280 65% 50%));
--gradient-warm: linear-gradient(180deg, hsl(30 30% 97%), hsl(30 25% 93%));
--shadow-glow: 0 0 40px hsl(24 95% 55% / 0.25);
--shadow-card: 0 8px 30px hsl(280 30% 12% / 0.08);
--shadow-elevated: 0 20px 50px hsl(280 30% 12% / 0.12);
/* Sidebar (unused but required) */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--background: 280 25% 8%;
--foreground: 30 20% 95%;
--card: 280 20% 12%;
--card-foreground: 30 20% 95%;
--popover: 280 20% 12%;
--popover-foreground: 30 20% 95%;
--primary: 24 95% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 340 82% 60%;
--secondary-foreground: 0 0% 100%;
--accent: 280 65% 55%;
--accent-foreground: 0 0% 100%;
--muted: 280 15% 18%;
--muted-foreground: 30 15% 65%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--destructive-foreground: 0 0% 100%;
--border: 280 15% 20%;
--input: 280 15% 20%;
--ring: 24 95% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
@@ -100,6 +101,49 @@ All colors MUST be HSL.
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground font-body antialiased;
}
h1, h2, h3, h4 {
@apply font-display;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
}
@layer components {
/* Tropical gradient text */
.text-gradient {
background: var(--gradient-tropical);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Tropical gradient background */
.bg-gradient-tropical {
background: var(--gradient-tropical);
}
/* Glow effect for buttons */
.shadow-glow {
box-shadow: var(--shadow-glow);
}
/* Card shadow */
.shadow-card {
box-shadow: var(--shadow-card);
}
.shadow-elevated {
box-shadow: var(--shadow-elevated);
}
/* Section padding utility */
.section-padding {
@apply px-4 py-16 md:px-8 md:py-24 lg:px-16;
}
}

View File

@@ -1,12 +1,37 @@
// Update this page (the content is just a fallback if you fail to update the page)
import Navbar from "@/components/Navbar";
import HeroSection from "@/components/HeroSection";
import AboutSection from "@/components/AboutSection";
import OrgSection from "@/components/OrgSection";
import StaffSection from "@/components/StaffSection";
import ScheduleSection from "@/components/ScheduleSection";
import BookingSection from "@/components/BookingSection";
import HotelSection from "@/components/HotelSection";
import PracticalSection from "@/components/PracticalSection";
import GallerySection from "@/components/GallerySection";
import FooterSection from "@/components/FooterSection";
import FloatingButton from "@/components/FloatingButton";
/**
* Landing page — Lambada Festival Barcelona
* by ZoukLambadaBCN
*
* Todos los datos editables están en: src/data/event-data.ts
*/
const Index = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
<div className="min-h-screen">
<Navbar />
<HeroSection />
<AboutSection />
<OrgSection />
<StaffSection />
<ScheduleSection />
<BookingSection />
<HotelSection />
<PracticalSection />
<GallerySection />
<FooterSection />
<FloatingButton />
</div>
);
};