mirror of
https://github.com/Ichitux/lambada-fiesta-live.git
synced 2026-05-15 16:32:20 +02:00
Changes
This commit is contained in:
63
src/components/AboutSection.tsx
Normal file
63
src/components/AboutSection.tsx
Normal 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;
|
||||
211
src/components/BookingSection.tsx
Normal file
211
src/components/BookingSection.tsx
Normal 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;
|
||||
46
src/components/FloatingButton.tsx
Normal file
46
src/components/FloatingButton.tsx
Normal 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;
|
||||
61
src/components/FooterSection.tsx
Normal file
61
src/components/FooterSection.tsx
Normal 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;
|
||||
49
src/components/GallerySection.tsx
Normal file
49
src/components/GallerySection.tsx
Normal 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;
|
||||
109
src/components/HeroSection.tsx
Normal file
109
src/components/HeroSection.tsx
Normal 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;
|
||||
61
src/components/HotelSection.tsx
Normal file
61
src/components/HotelSection.tsx
Normal 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
80
src/components/Navbar.tsx
Normal 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;
|
||||
81
src/components/OrgSection.tsx
Normal file
81
src/components/OrgSection.tsx
Normal 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;
|
||||
89
src/components/PracticalSection.tsx
Normal file
89
src/components/PracticalSection.tsx
Normal 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;
|
||||
74
src/components/ScheduleSection.tsx
Normal file
74
src/components/ScheduleSection.tsx
Normal 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;
|
||||
89
src/components/StaffSection.tsx
Normal file
89
src/components/StaffSection.tsx
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user