mirror of
https://github.com/Ichitux/lambada-fiesta-live.git
synced 2026-05-16 13:02:20 +02:00
Added multi-lingua, english-spanish. Timer conditions and Linting bugfixes.
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 29s
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 29s
This commit is contained in:
@@ -1,63 +1,68 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { ABOUT_EVENT } from "@/data/event-data";
|
||||
import aboutImg from "@/assets/about-event.jpg";
|
||||
import { Music, Users, Sparkles, PartyPopper } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const iconMap = [Music, Users, Sparkles, PartyPopper];
|
||||
|
||||
const AboutSection = () => (
|
||||
<section id="about" className="section-padding bg-background relative z-10 -mt-[50px] pt-[100px]">
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Imagen */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<img
|
||||
src={aboutImg}
|
||||
alt="Evento de Lambada"
|
||||
className="rounded-2xl shadow-elevated w-full object-cover aspect-square"
|
||||
/>
|
||||
</motion.div>
|
||||
const AboutSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const highlightKeys = ["workshops", "socialDancing", "liveShows", "djSets"] as const;
|
||||
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal mb-6 text-gradient">
|
||||
{ABOUT_EVENT.title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed whitespace-pre-line">
|
||||
{ABOUT_EVENT.description}
|
||||
</p>
|
||||
<p className="text-foreground/90 mb-8 leading-relaxed italic border-l-4 border-primary pl-4">
|
||||
{ABOUT_EVENT.lambadaInfo}
|
||||
</p>
|
||||
return (
|
||||
<section id="about" className="section-padding bg-background relative z-10 -mt-[50px] pt-[100px]">
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Imagen */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<img
|
||||
src={aboutImg}
|
||||
alt={t("about.title")}
|
||||
className="rounded-2xl shadow-elevated w-full object-cover aspect-square"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Highlights */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{ABOUT_EVENT.highlights.map((item, i) => {
|
||||
const Icon = iconMap[i % iconMap.length];
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 bg-card rounded-lg p-3">
|
||||
<div className="bg-gradient-tropical rounded-full p-2 shrink-0">
|
||||
<Icon className="w-4 h-4 text-primary-foreground" />
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal mb-6 text-gradient">
|
||||
{t("about.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed whitespace-pre-line">
|
||||
{t("about.description")}
|
||||
</p>
|
||||
<p className="text-foreground/90 mb-8 leading-relaxed italic border-l-4 border-primary pl-4">
|
||||
{t("about.lambadaInfo")}
|
||||
</p>
|
||||
|
||||
{/* Highlights */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{highlightKeys.map((key, i) => {
|
||||
const Icon = iconMap[i % iconMap.length];
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 bg-card rounded-lg p-3">
|
||||
<div className="bg-gradient-tropical rounded-full p-2 shrink-0">
|
||||
<Icon className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{t(`about.highlights.${key}`)}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{item}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutSection;
|
||||
|
||||
@@ -4,6 +4,9 @@ import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WEBHOOK_URL, WEBHOOK_SECRET } from "@/data/event-data";
|
||||
import { CheckCircle, Loader2, AlertCircle, Search } from "lucide-react";
|
||||
import { EVENT_INFO } from "@/data/event-data";
|
||||
import { getTimeLeft } from "@/components/HeroSection";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* FORMULARIO DE RESERVA
|
||||
@@ -17,25 +20,25 @@ import { CheckCircle, Loader2, AlertCircle, Search } from "lucide-react";
|
||||
|
||||
// Define pass types with prices
|
||||
const PASS_TYPES = [
|
||||
{ id: "full", name: "Full Pass", price: 150 },
|
||||
{ id: "party", name: "Party Pass", price: 80 },
|
||||
{ id: "single", name: "Single Day Pass", price: 40 },
|
||||
{ id: "full", price: 150 },
|
||||
{ id: "party", price: 80 },
|
||||
{ id: "single", price: 40 },
|
||||
];
|
||||
|
||||
const bookingSchema = z.object({
|
||||
requestId: z.string().optional(),
|
||||
name: z.string().trim().min(2, "El nombre debe tener al menos 2 caracteres").max(100),
|
||||
surname: z.string().trim().min(2, "El apellido debe tener al menos 2 caracteres").max(100),
|
||||
email: z.string().trim().email("Email no válido").max(255),
|
||||
passType: z.string().min(1, "Selecciona un tipo de pass"),
|
||||
amount: z.string().min(1, "Selecciona la cantidad"),
|
||||
price: z.number().optional(),
|
||||
country: z.string().trim().min(2, "Indica tu país").max(100),
|
||||
});
|
||||
|
||||
type BookingData = z.infer<typeof bookingSchema>;
|
||||
type BookingData = {
|
||||
requestId?: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
passType: string;
|
||||
amount: string;
|
||||
price: number;
|
||||
country: string;
|
||||
};
|
||||
|
||||
const BookingSection = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [form, setForm] = useState<BookingData>({
|
||||
requestId: "", name: "", surname: "", email: "", passType: "", amount: "1", price: 0, country: "",
|
||||
});
|
||||
@@ -46,6 +49,31 @@ const BookingSection = () => {
|
||||
const countryDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const countryInputRef = useRef<HTMLInputElement>(null);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
const bookingSchema = z.object({
|
||||
requestId: z.string().optional(),
|
||||
name: z.string().trim().min(2, i18n.t("booking.validation.nameMin")).max(100),
|
||||
surname: z.string().trim().min(2, i18n.t("booking.validation.surnameMin")).max(100),
|
||||
email: z.string().trim().email(i18n.t("booking.validation.emailInvalid")).max(255),
|
||||
passType: z.string().min(1, i18n.t("booking.validation.passTypeRequired")),
|
||||
amount: z.string().min(1, i18n.t("booking.validation.amountRequired")),
|
||||
price: z.number().optional(),
|
||||
country: z.string().trim().min(2, i18n.t("booking.validation.countryMin")).max(100),
|
||||
});
|
||||
|
||||
const updatePassTypeOrAmount = (field: "passType" | "amount", value: string) => {
|
||||
setForm((prev) => {
|
||||
const updatedForm = { ...prev, [field]: value } as BookingData;
|
||||
const selectedPass = PASS_TYPES.find((pass) => pass.id === (field === "passType" ? value : updatedForm.passType));
|
||||
const amountValue = parseInt(updatedForm.amount);
|
||||
const newPrice = selectedPass ? selectedPass.price * amountValue : updatedForm.price;
|
||||
|
||||
return { ...updatedForm, price: newPrice };
|
||||
});
|
||||
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
};
|
||||
|
||||
// Generate unique requestId on component mount
|
||||
useEffect(() => {
|
||||
const uniqueId = `REQ-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
@@ -121,7 +149,7 @@ const BookingSection = () => {
|
||||
|
||||
const result = bookingSchema.safeParse(form);
|
||||
if (!result.success) {
|
||||
const fieldErrors: typeof errors = {};
|
||||
const fieldErrors: Partial<Record<keyof BookingData, string>> = {};
|
||||
result.error.errors.forEach((err) => {
|
||||
const field = err.path[0] as keyof BookingData;
|
||||
fieldErrors[field] = err.message;
|
||||
@@ -151,6 +179,16 @@ const BookingSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
|
||||
const isEventOngoing = timeLeft.days === 0 && timeLeft.hours === 0 && timeLeft.minutes === 0 && timeLeft.seconds === 0;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft(getTimeLeft(EVENT_INFO.date));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<section
|
||||
@@ -166,10 +204,35 @@ const BookingSection = () => {
|
||||
>
|
||||
<CheckCircle className="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
|
||||
¡Reserva recibida!
|
||||
{t("booking.status.successTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Recibirás confirmación por email. ¡Nos vemos en la pista!
|
||||
{t("booking.status.success")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEventOngoing) {
|
||||
return (
|
||||
<section
|
||||
id="booking"
|
||||
ref={sectionRef}
|
||||
className="section-padding bg-background scroll-mt-24 relative z-10 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 120px 0 0" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-lg mx-auto text-center bg-card rounded-2xl p-10 shadow-elevated"
|
||||
>
|
||||
<AlertCircle className="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
|
||||
{t("booking.status.eventInProgressTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t("booking.status.eventInProgressDescription")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
@@ -191,11 +254,9 @@ const BookingSection = () => {
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
Reserva tu pase
|
||||
{t("booking.subtitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Selecciona tu pase y cantidad. Sin pago online, paga en el evento.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("booking.description")}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.form
|
||||
@@ -211,26 +272,30 @@ const BookingSection = () => {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Nombre *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t("booking.formFields.name")} *
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Tu nombre"
|
||||
placeholder={t("booking.formFields.name")}
|
||||
/>
|
||||
{errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Apellido */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Apellido *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t("booking.formFields.surname")} *
|
||||
</label>
|
||||
<input
|
||||
name="surname"
|
||||
value={form.surname}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Tu apellido"
|
||||
placeholder={t("booking.formFields.surname")}
|
||||
/>
|
||||
{errors.surname && <p className="text-destructive text-xs mt-1">{errors.surname}</p>}
|
||||
</div>
|
||||
@@ -238,21 +303,25 @@ const BookingSection = () => {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Email *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t("booking.formFields.email")} *
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="tu@email.com"
|
||||
placeholder={t("booking.formFields.email")}
|
||||
/>
|
||||
{errors.email && <p className="text-destructive text-xs mt-1">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* País - Searchable Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">País *</label>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{t("booking.formFields.country")} *
|
||||
</label>
|
||||
<div className="relative" ref={countryInputRef}>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -263,7 +332,7 @@ const BookingSection = () => {
|
||||
setShowCountryDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCountryDropdown(true)}
|
||||
placeholder="Buscar país..."
|
||||
placeholder={t("booking.formFields.countryPlaceholder")}
|
||||
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring pr-10"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
@@ -290,7 +359,7 @@ const BookingSection = () => {
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
No se encontraron países
|
||||
{t("booking.formFields.countryNoResults")}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -302,7 +371,9 @@ const BookingSection = () => {
|
||||
|
||||
{/* Tipo de Pass - Cards */}
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-foreground mb-3">Tipo de Pass *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-3">
|
||||
{t("booking.formFields.passType")} *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{PASS_TYPES.map((pass) => (
|
||||
<motion.div
|
||||
@@ -314,10 +385,16 @@ const BookingSection = () => {
|
||||
? "border-primary bg-primary/5 shadow-lg"
|
||||
: "border-border hover:border-primary/50 hover:shadow-md"
|
||||
}`}
|
||||
onClick={() => handleChange({ target: { name: "passType", value: pass.id } } as any)}
|
||||
onClick={() => updatePassTypeOrAmount("passType", pass.id)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg text-foreground mb-1">{pass.name}</h3>
|
||||
<h3 className="font-semibold text-lg text-foreground mb-1">
|
||||
{pass.id === "full"
|
||||
? t("booking.fullPass")
|
||||
: pass.id === "party"
|
||||
? t("booking.partyPass")
|
||||
: t("booking.singleDayPass")}
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-primary">{pass.price}€</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -328,7 +405,9 @@ const BookingSection = () => {
|
||||
|
||||
{/* Cantidad - Add/Subtract Buttons */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-foreground">Cantidad *</label>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{t("booking.quantity")} *
|
||||
</label>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<motion.button
|
||||
type="button"
|
||||
@@ -338,7 +417,7 @@ const BookingSection = () => {
|
||||
if (!form.passType) return;
|
||||
const currentAmount = parseInt(form.amount);
|
||||
if (currentAmount > 1) {
|
||||
handleChange({ target: { name: "amount", value: String(currentAmount - 1) } } as any);
|
||||
updatePassTypeOrAmount("amount", String(currentAmount - 1));
|
||||
}
|
||||
}}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
|
||||
@@ -367,7 +446,7 @@ const BookingSection = () => {
|
||||
if (!form.passType) return;
|
||||
const currentAmount = parseInt(form.amount);
|
||||
if (currentAmount < 10) {
|
||||
handleChange({ target: { name: "amount", value: String(currentAmount + 1) } } as any);
|
||||
updatePassTypeOrAmount("amount", String(currentAmount + 1));
|
||||
}
|
||||
}}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
|
||||
@@ -391,9 +470,9 @@ const BookingSection = () => {
|
||||
className="bg-gradient-to-r from-primary/10 to-primary/5 rounded-xl p-6 border border-primary/20"
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-1">Precio Total</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t("booking.priceSummaryLabel")}</p>
|
||||
<p className="text-3xl font-bold text-primary">{form.price.toFixed(2)}€</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">Sin pago online • Paga en el evento</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("booking.priceSummaryNote")}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -401,7 +480,7 @@ const BookingSection = () => {
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 text-destructive text-sm bg-destructive/10 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Error al enviar. Inténtalo de nuevo.
|
||||
{t("booking.status.error")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -413,9 +492,11 @@ const BookingSection = () => {
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</>
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> {t("booking.status.loading")}
|
||||
</>
|
||||
) : (
|
||||
"Reservar Pass"
|
||||
t("booking.buyButton")
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** Botón flotante de reserva + scroll to top */
|
||||
const FloatingButton = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > 400);
|
||||
@@ -42,13 +44,13 @@ const FloatingButton = () => {
|
||||
<path d="M4.25 20a.75.75 0 0 1-.743-.653l-.23-1.61a3.75 3.75 0 0 1 1.64-3.67l2.41-1.6a1.5 1.5 0 0 0 .67-1.1l.16-1.3a2.25 2.25 0 0 1 2.22-1.97h.2a2.25 2.25 0 0 1 1.98 1.19l.54 1.03 1.66.83a3 3 0 0 1 1.33 1.22l1.76 2.89c.23.38.09.87-.29 1.1a.8.8 0 0 1-1.08-.28l-1.47-2.42a1.5 1.5 0 0 0-.67-.61l-1.63-.81-.25 1.77a2.25 2.25 0 0 1-1.01 1.55l-2.98 2a2.25 2.25 0 0 0-.98 1.64l-.06.92A.75.75 0 0 1 6 20H4.25Z" />
|
||||
<path d="M14.75 6.5a1 1 0 0 1 1.5-.86l2 .99a2.5 2.5 0 0 1 1.12 3.26l-.37.78a.75.75 0 1 1-1.36-.64l.37-.78a1 1 0 0 0-.45-1.3l-1.99-.99a1 1 0 0 1-.32-1.46Z" />
|
||||
</svg>
|
||||
Reservar
|
||||
{t("nav.booking")}
|
||||
</a>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
className="self-center bg-gray-500 text-white p-2 rounded-full hover:bg-gray-600 transition-colors shadow"
|
||||
aria-label="Volver arriba"
|
||||
aria-label={t("common.toTop")}
|
||||
>
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -1,72 +1,99 @@
|
||||
import { ABOUT_ORG, FOOTER } from "@/data/event-data";
|
||||
import { ABOUT_ORG } from "@/data/event-data";
|
||||
import { Instagram, Facebook, Youtube, Mail } from "lucide-react";
|
||||
import hacecalor from "@/assets/hacecalor.png";
|
||||
import activat from "@/assets/activat.png";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const FooterSection = () => (
|
||||
<motion.footer
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="bg-foreground text-primary-foreground relative z-30 -mt-[40px] pt-[80px]"
|
||||
style={{ clipPath: "polygon(50% 0, 100% 40px, 100% 100%, 0 100%, 0 40px)" }}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<h3 className="font-display text-3xl md:text-4xl lg:text-5xl pt-3 pb-6 break-words leading-[1.6] font-bold mb-2">ZoukLambadaBCN</h3>
|
||||
<p className="text-primary-foreground/70 text-sm">
|
||||
<img src={hacecalor} alt="Hacecalor" className="inline-block w-auto h-12 mr-1" />
|
||||
<img src={activat} alt="Activat" className="inline-block w-auto h-12 mr-1" />
|
||||
</p>
|
||||
</div>
|
||||
const FooterSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const year = new Date().getFullYear();
|
||||
const email = t("footer.email");
|
||||
const copyright = t("footer.copyright", { year });
|
||||
|
||||
{/* Contacto */}
|
||||
<div>
|
||||
<h4 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">Contacto</h4>
|
||||
<a
|
||||
href={`mailto:${FOOTER.email}`}
|
||||
className="flex items-center gap-2 text-sm text-primary-foreground/70 hover:text-primary transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{FOOTER.email}
|
||||
</a>
|
||||
</div>
|
||||
return (
|
||||
<motion.footer
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="bg-foreground text-primary-foreground relative z-30 -mt-[40px] pt-[80px]"
|
||||
style={{ clipPath: "polygon(50% 0, 100% 40px, 100% 100%, 0 100%, 0 40px)" }}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<h3 className="font-display text-3xl md:text-4xl lg:text-5xl pt-3 pb-6 break-words leading-[1.6] font-bold mb-2">ZoukLambadaBCN</h3>
|
||||
<p className="text-primary-foreground/70 text-sm">
|
||||
<img src={hacecalor} alt="Hacecalor" className="inline-block w-auto h-12 mr-1" />
|
||||
<img src={activat} alt="Activat" className="inline-block w-auto h-12 mr-1" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Redes */}
|
||||
<div>
|
||||
<h4 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">Síguenos</h4>
|
||||
<div className="flex gap-3">
|
||||
{ABOUT_ORG.socials.instagram && (
|
||||
<a href={ABOUT_ORG.socials.instagram} target="_blank" rel="noopener noreferrer" aria-label="Instagram"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
|
||||
<Instagram className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.facebook && (
|
||||
<a href={ABOUT_ORG.socials.facebook} target="_blank" rel="noopener noreferrer" aria-label="Facebook"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.youtube && (
|
||||
<a href={ABOUT_ORG.socials.youtube} target="_blank" rel="noopener noreferrer" aria-label="YouTube"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors">
|
||||
<Youtube className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{/* Contacto */}
|
||||
<div>
|
||||
<h4 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">
|
||||
{t("footer.contact")}
|
||||
</h4>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="flex items-center gap-2 text-sm text-primary-foreground/70 hover:text-primary transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Redes */}
|
||||
<div>
|
||||
<h4 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-semibold mb-2">
|
||||
{t("footer.followUs")}
|
||||
</h4>
|
||||
<div className="flex gap-3">
|
||||
{ABOUT_ORG.socials.instagram && (
|
||||
<a
|
||||
href={ABOUT_ORG.socials.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors"
|
||||
>
|
||||
<Instagram className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.facebook && (
|
||||
<a
|
||||
href={ABOUT_ORG.socials.facebook}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Facebook"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors"
|
||||
>
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{ABOUT_ORG.socials.youtube && (
|
||||
<a
|
||||
href={ABOUT_ORG.socials.youtube}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="YouTube"
|
||||
className="bg-primary-foreground/10 p-2.5 rounded-full hover:bg-primary-foreground/20 transition-colors"
|
||||
>
|
||||
<Youtube className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-primary-foreground/10 pt-6 text-center">
|
||||
<p className="text-sm text-primary-foreground/50">{FOOTER.copyright}</p>
|
||||
<div className="border-t border-primary-foreground/10 pt-6 text-center">
|
||||
<p className="text-sm text-primary-foreground/50">{copyright}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.footer>
|
||||
);
|
||||
</motion.footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSection;
|
||||
|
||||
@@ -1,53 +1,58 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { GALLERY_IMAGES } from "@/data/event-data";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const GallerySection = () => (
|
||||
<section
|
||||
id="gallery"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 100px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
Galería
|
||||
</h2>
|
||||
</motion.div>
|
||||
const GallerySection = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-5xl mx-auto">
|
||||
{GALLERY_IMAGES.map((img, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="aspect-square rounded-xl overflow-hidden bg-muted flex items-center justify-center"
|
||||
>
|
||||
{img.src ? (
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground/40">
|
||||
<ImageIcon className="w-10 h-10 mx-auto mb-2" />
|
||||
<p className="text-xs">{img.alt}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
return (
|
||||
<section
|
||||
id="gallery"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[120px]"
|
||||
style={{ borderRadius: "0 100% 0 0 / 0 100px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("gallery.title")}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-5xl mx-auto">
|
||||
{GALLERY_IMAGES.map((img, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="aspect-square rounded-xl overflow-hidden bg-muted flex items-center justify-center"
|
||||
>
|
||||
{img.src ? (
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground/40">
|
||||
<ImageIcon className="w-10 h-10 mx-auto mb-2" />
|
||||
<p className="text-xs">{img.alt}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EVENT_INFO } from "@/data/event-data";
|
||||
import heroBg from "@/assets/hero-bg.jpg";
|
||||
|
||||
/** Calcula diferencia entre ahora y la fecha del evento */
|
||||
const getTimeLeft = (target: string) => {
|
||||
export const getTimeLeft = (target: string) => {
|
||||
const diff = new Date(target).getTime() - Date.now();
|
||||
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
return {
|
||||
@@ -17,6 +18,7 @@ const getTimeLeft = (target: string) => {
|
||||
};
|
||||
|
||||
const HeroSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,10 +29,10 @@ const HeroSection = () => {
|
||||
}, []);
|
||||
|
||||
const countdownItems = [
|
||||
{ value: timeLeft.days, label: "Días" },
|
||||
{ value: timeLeft.hours, label: "Horas" },
|
||||
{ value: timeLeft.minutes, label: "Min" },
|
||||
{ value: timeLeft.seconds, label: "Seg" },
|
||||
{ value: timeLeft.days, label: t('hero.days') },
|
||||
{ value: timeLeft.hours, label: t('hero.hours') },
|
||||
{ value: timeLeft.minutes, label: t('hero.minutes') },
|
||||
{ value: timeLeft.seconds, label: t('hero.seconds') },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -53,7 +55,7 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-primary font-body text-sm uppercase tracking-[0.3em] mb-4"
|
||||
>
|
||||
{EVENT_INFO.dateDisplay} · {EVENT_INFO.city}
|
||||
{t('hero.dates')} · {t('hero.location')}
|
||||
</motion.p>
|
||||
|
||||
<motion.h1
|
||||
@@ -62,7 +64,7 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.4 }}
|
||||
className="font-hero text-4xl md:text-7xl lg:text-8xl font-bold text-primary-foreground mb-4 leading-tight"
|
||||
>
|
||||
{EVENT_INFO.name}
|
||||
{t('hero.title')}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
@@ -71,7 +73,7 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.6 }}
|
||||
className="text-primary-foreground/80 font-body text-lg md:text-xl mb-10"
|
||||
>
|
||||
{EVENT_INFO.subtitle}
|
||||
{t('hero.subtitle')}
|
||||
</motion.p>
|
||||
|
||||
{/* Countdown */}
|
||||
@@ -81,18 +83,31 @@ const HeroSection = () => {
|
||||
transition={{ delay: 0.8 }}
|
||||
className="flex justify-center gap-4 md:gap-8 mb-10"
|
||||
>
|
||||
{countdownItems.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="bg-primary-foreground/10 backdrop-blur-sm border border-primary-foreground/20 rounded-lg px-4 py-3 md:px-6 md:py-4 min-w-[60px] md:min-w-[80px]">
|
||||
{timeLeft.days === 0 && timeLeft.hours === 0 && timeLeft.minutes === 0 && timeLeft.seconds === 0 ? (
|
||||
<div className="text-center">
|
||||
<div className="bg-primary-foreground/10 backdrop-blur-sm border border-primary-foreground/20 rounded-lg px-6 py-4 min-w-[180px]">
|
||||
<span className="text-2xl md:text-4xl font-hero font-bold text-primary-foreground">
|
||||
{String(item.value).padStart(2, "0")}
|
||||
{t('hero.eventInProgress')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-primary-foreground/60 text-xs mt-2 uppercase tracking-wider">
|
||||
{item.label}
|
||||
{t('hero.dontMiss')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
countdownItems.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="bg-primary-foreground/10 backdrop-blur-sm border border-primary-foreground/20 rounded-lg px-4 py-3 md:px-6 md:py-4 min-w-[60px] md:min-w-[80px]">
|
||||
<span className="text-2xl md:text-4xl font-hero font-bold text-primary-foreground">
|
||||
{String(item.value).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-primary-foreground/60 text-xs mt-2 uppercase tracking-wider">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -101,7 +116,7 @@ const HeroSection = () => {
|
||||
transition={{ delay: 1 }}
|
||||
>
|
||||
<Button variant="hero" size="lg" className="text-lg px-10 py-6" asChild>
|
||||
<a href="#booking">Reservar tu pase</a>
|
||||
<a href="#booking">{t('hero.bookYourPass')}</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -2,64 +2,79 @@ import { motion } from "framer-motion";
|
||||
import { HOTEL_ROOMS } from "@/data/event-data";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BedDouble, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const HotelSection = () => (
|
||||
<section
|
||||
id="hotel"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "50% 50% 0 0 / 60px 60px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
Alojamiento
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Habitaciones recomendadas cerca del venue.
|
||||
</p>
|
||||
</motion.div>
|
||||
const HotelSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const roomTypeKeyById: Record<string, "individual" | "double" | "suite"> = {
|
||||
"1": "individual",
|
||||
"2": "double",
|
||||
"3": "suite",
|
||||
};
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{HOTEL_ROOMS.map((room, i) => (
|
||||
<motion.div
|
||||
key={room.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-background rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow"
|
||||
>
|
||||
{/* Imagen */}
|
||||
<div className="aspect-video bg-muted flex items-center justify-center overflow-hidden">
|
||||
{room.image ? (
|
||||
<img src={room.image} alt={room.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<BedDouble className="w-12 h-12 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<section
|
||||
id="hotel"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "50% 50% 0 0 / 60px 60px 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("hotel.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">{t("hotel.subtitle")}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="p-5">
|
||||
<h3 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-1">
|
||||
{room.name}
|
||||
</h3>
|
||||
<p className="text-primary font-bold text-xl mb-2">{room.price}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">{room.description}</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href={room.link} target="_blank" rel="noopener noreferrer">
|
||||
Reservar en el hotel <ExternalLink className="w-4 h-4 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{HOTEL_ROOMS.map((room, i) => {
|
||||
const typeKey = roomTypeKeyById[room.id];
|
||||
const translatedName = typeKey ? t(`hotel.${typeKey}`) : room.name;
|
||||
const translatedPrice = room.price?.includes("[") ? t("hotel.pricePerNight") : room.price;
|
||||
const translatedDescription = room.description?.includes("[") ? t("hotel.description") : room.description;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={room.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-background rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow"
|
||||
>
|
||||
{/* Imagen */}
|
||||
<div className="aspect-video bg-muted flex items-center justify-center overflow-hidden">
|
||||
{room.image ? (
|
||||
<img src={room.image} alt={translatedName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<BedDouble className="w-12 h-12 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<h3 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-1">
|
||||
{translatedName}
|
||||
</h3>
|
||||
<p className="text-primary font-bold text-xl mb-2">{translatedPrice}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">{translatedDescription}</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href={room.link} target="_blank" rel="noopener noreferrer">
|
||||
{t("hotel.bookButton")} <ExternalLink className="w-4 h-4 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotelSection;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { Menu, X, Globe } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NAV_LINKS } from "@/data/event-data";
|
||||
import Logo from "@/assets/logo.png";
|
||||
|
||||
@@ -11,6 +12,12 @@ import Logo from "@/assets/logo.png";
|
||||
const Navbar = () => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === 'es' ? 'en' : 'es';
|
||||
i18n.changeLanguage(newLang);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 50);
|
||||
@@ -49,9 +56,21 @@ const Navbar = () => {
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-black/80 hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
{t(`nav.${link.href.substring(1)}`)}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Language Switcher */}
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<Globe size={18} />
|
||||
<span className="text-sm font-medium">
|
||||
{i18n.language === 'es' ? 'EN' : 'ES'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
@@ -85,9 +104,22 @@ const Navbar = () => {
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="text-3xl md:text-4xl lg:text-5xl pt-3 pb-6 break-words leading-[1.6] font-display font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
{t(`nav.${link.href.substring(1)}`)}
|
||||
</motion.a>
|
||||
))}
|
||||
|
||||
{/* Language Switcher */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 30 }}
|
||||
transition={{ delay: 0.1 * NAV_LINKS.length }}
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center gap-3 text-3xl md:text-4xl lg:text-5xl pt-3 pb-6 font-display font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Globe className="w-8 h-8 md:w-10 md:h-10" />
|
||||
{i18n.language === 'es' ? 'EN' : 'ES'}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -2,30 +2,33 @@ import { motion } from "framer-motion";
|
||||
import { ABOUT_ORG } from "@/data/event-data";
|
||||
import communityImg from "@/assets/community.jpg";
|
||||
import { Instagram, Facebook, Youtube } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const maskStyle = "radial-gradient(196.7px at 50% 264px, rgb(0, 0, 0) 99%, rgba(0, 0, 0, 0) 101%) calc(50% - 176px) 0px / 352px 100%, radial-gradient(196.7px at 50% -176px, rgba(0, 0, 0, 0) 99%, rgb(0, 0, 0) 101%) 50% 78px / 352px 100% repeat-x";
|
||||
|
||||
const OrgSection = () => (
|
||||
<section
|
||||
className="section-padding bg-card pb-20 md:pb-28 pt-[80px] md:pt-[100px] -mt-[32px] relative z-20"
|
||||
style={{ WebkitMask: maskStyle, mask: maskStyle }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal mb-6 text-gradient">
|
||||
{ABOUT_ORG.title}
|
||||
</h2>
|
||||
<div className="space-y-4 text-muted-foreground leading-relaxed">
|
||||
<p className="whitespace-pre-line">{ABOUT_ORG.history}</p>
|
||||
<p className="whitespace-pre-line">{ABOUT_ORG.philosophy}</p>
|
||||
</div>
|
||||
const OrgSection = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section
|
||||
className="section-padding bg-card pb-20 md:pb-28 pt-[80px] md:pt-[100px] -mt-[32px] relative z-20"
|
||||
style={{ WebkitMask: maskStyle, mask: maskStyle }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Texto */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal mb-6 text-gradient">
|
||||
{t("about.orgTitle")}
|
||||
</h2>
|
||||
<div className="space-y-4 text-muted-foreground leading-relaxed">
|
||||
<p className="whitespace-pre-line">{t("about.orgDescription")}</p>
|
||||
</div>
|
||||
|
||||
{/* Redes sociales */}
|
||||
<div className="flex gap-4 mt-8">
|
||||
@@ -63,7 +66,7 @@ const OrgSection = () => (
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Imagen */}
|
||||
<motion.div
|
||||
@@ -78,9 +81,10 @@ const OrgSection = () => (
|
||||
className="rounded-2xl shadow-elevated w-full h-auto object-contain bg-muted"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrgSection;
|
||||
|
||||
@@ -1,93 +1,103 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { EVENT_INFO, PRACTICAL_INFO } from "@/data/event-data";
|
||||
import { MapPin, Plane, Train } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PracticalSection = () => (
|
||||
<section
|
||||
id="info"
|
||||
className="section-padding bg-background relative z-10 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 100px 0 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal text-gradient mb-4">
|
||||
Información Práctica
|
||||
</h2>
|
||||
</motion.div>
|
||||
const PracticalSection = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Mapa */}
|
||||
return (
|
||||
<section
|
||||
id="info"
|
||||
className="section-padding bg-background relative z-10 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 100px 0 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<div className="rounded-2xl overflow-hidden shadow-card mb-4 aspect-video">
|
||||
<iframe
|
||||
src={EVENT_INFO.mapEmbedUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title="Ubicación del evento"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<MapPin className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal text-gradient mb-4">
|
||||
{t("info.title")}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Mapa */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
>
|
||||
<div className="rounded-2xl overflow-hidden shadow-card mb-4 aspect-video">
|
||||
<iframe
|
||||
src={EVENT_INFO.mapEmbedUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title={t("info.mapTitle")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<MapPin className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<p className="font-semibold">{EVENT_INFO.venue}</p>
|
||||
<p className="text-sm text-muted-foreground">{EVENT_INFO.venueAddress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Aeropuertos */}
|
||||
<div>
|
||||
<p className="font-semibold">{EVENT_INFO.venue}</p>
|
||||
<p className="text-sm text-muted-foreground">{EVENT_INFO.venueAddress}</p>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2 flex items-center gap-2">
|
||||
<Plane className="w-5 h-5 text-primary" /> {t("info.airports")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.airports.map((a) => (
|
||||
<div key={a.name} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.distance}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Aeropuertos */}
|
||||
<div>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2 flex items-center gap-2">
|
||||
<Plane className="w-5 h-5 text-primary" /> Aeropuertos Cercanos
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.airports.map((a) => (
|
||||
<div key={a.name} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.distance}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Cómo llegar */}
|
||||
<div>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2 flex items-center gap-2">
|
||||
<Train className="w-5 h-5 text-primary" /> {t("info.howToGet")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.howToGet.map((h) => {
|
||||
const methodLabel =
|
||||
h.method === "Metro" ? t("info.metro") : h.method === "Bus" ? t("info.bus") : h.method === "Taxi/Uber" ? t("info.taxi") : h.method;
|
||||
|
||||
{/* Cómo llegar */}
|
||||
<div>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2 flex items-center gap-2">
|
||||
<Train className="w-5 h-5 text-primary" /> Cómo Llegar
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{PRACTICAL_INFO.howToGet.map((h) => (
|
||||
<div key={h.method} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{h.method}</p>
|
||||
<p className="text-xs text-muted-foreground">{h.details}</p>
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<div key={h.method} className="bg-card rounded-lg p-3">
|
||||
<p className="font-medium text-foreground text-sm">{methodLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">{h.details}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PracticalSection;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { SCHEDULE } from "@/data/event-data";
|
||||
import { useState, useEffect } from "react";
|
||||
import { SCHEDULE, EVENT_INFO } from "@/data/event-data";
|
||||
import { Clock, Music, Coffee, Star } from "lucide-react";
|
||||
import { getTimeLeft } from "@/components/HeroSection";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const typeIcon: Record<string, typeof Clock> = {
|
||||
workshop: Clock,
|
||||
@@ -16,63 +19,120 @@ const typeColor: Record<string, string> = {
|
||||
show: "border-accent bg-accent/10 text-accent",
|
||||
};
|
||||
|
||||
const ScheduleSection = () => (
|
||||
<section
|
||||
id="schedule"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 120px 0 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
Programa
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Tres días de workshops, shows y social dance.
|
||||
</p>
|
||||
</motion.div>
|
||||
const ScheduleSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
|
||||
const isEventOngoing = timeLeft.days === 0 && timeLeft.hours === 0 && timeLeft.minutes === 0 && timeLeft.seconds === 0;
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{SCHEDULE.map((day, di) => (
|
||||
<motion.div
|
||||
key={day.day}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: di * 0.15 }}
|
||||
>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-4 pb-3 border-b-2 border-primary">
|
||||
{day.day}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{day.events.map((event, ei) => {
|
||||
const Icon = typeIcon[event.type] || Clock;
|
||||
return (
|
||||
<div
|
||||
key={ei}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border-l-4 ${
|
||||
typeColor[event.type] || ""
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium opacity-70">{event.time}</p>
|
||||
<p className="text-sm font-semibold">{event.title}</p>
|
||||
const dayKeys = ["friday", "saturday", "sunday"] as const;
|
||||
|
||||
const formatEventTitle = (event: { type: string; title: string }, dayIndex: number) => {
|
||||
switch (event.type) {
|
||||
case "workshop": {
|
||||
const match = event.title.match(/Workshop\\s*(\\d+)/i);
|
||||
return match?.[1] ? `${t("schedule.workshop")} ${match[1]}` : t("schedule.workshop");
|
||||
}
|
||||
case "break":
|
||||
return t("schedule.break");
|
||||
case "show":
|
||||
return t("schedule.show");
|
||||
case "social":
|
||||
return dayIndex === 2 ? t("schedule.farewell") : t("schedule.social");
|
||||
default:
|
||||
return event.title;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft(getTimeLeft(EVENT_INFO.date));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!isEventOngoing) {
|
||||
return (
|
||||
<section
|
||||
id="schedule"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 120px 0 0 0" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-lg mx-auto text-center bg-card rounded-2xl p-10 shadow-elevated"
|
||||
>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
|
||||
{t("schedule.statusBriefTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t("schedule.statusBriefDescription")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id="schedule"
|
||||
className="section-padding bg-card relative z-20 -mt-[40px] pt-[100px]"
|
||||
style={{ borderRadius: "100% 0 0 0 / 120px 0 0 0" }}
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
|
||||
{t("schedule.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("schedule.subtitle")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{SCHEDULE.map((day, di) => (
|
||||
<motion.div
|
||||
key={day.day}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ delay: di * 0.15 }}
|
||||
>
|
||||
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-3 leading-[1.6] font-bold text-foreground mb-4 border-b-2 border-primary">
|
||||
{t(`schedule.${dayKeys[di]}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{day.events.map((event, ei) => {
|
||||
const Icon = typeIcon[event.type] || Clock;
|
||||
return (
|
||||
<div
|
||||
key={ei}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border-l-4 ${
|
||||
typeColor[event.type] || ""
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium opacity-70">{event.time}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatEventTitle(event as { type: string; title: string }, di)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleSection;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { STAFF } from "@/data/event-data";
|
||||
import { Instagram, User, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** Colores de badge por rol */
|
||||
const roleBadgeClass: Record<string, string> = {
|
||||
@@ -11,12 +12,13 @@ const roleBadgeClass: Record<string, string> = {
|
||||
};
|
||||
|
||||
const StaffSection = () => {
|
||||
const [filter, setFilter] = useState<string>("All");
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<"all" | "instructors" | "djs">("all");
|
||||
|
||||
const filteredStaff = STAFF.filter((member) => {
|
||||
if (filter === "All") return true;
|
||||
if (filter === "Instructors") return member.role === "Instructor";
|
||||
if (filter === "DJs") return member.role === "DJ";
|
||||
if (filter === "all") return true;
|
||||
if (filter === "instructors") return member.role === "Instructor";
|
||||
if (filter === "djs") return member.role === "DJ";
|
||||
return true;
|
||||
});
|
||||
// simple ref + helpers for horizontal scroll
|
||||
@@ -50,10 +52,10 @@ const StaffSection = () => {
|
||||
>
|
||||
<div className="text-center md:text-left w-full">
|
||||
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-6 leading-normal text-gradient mb-2">
|
||||
Staff del Evento
|
||||
{t("staff.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto md:mx-0">
|
||||
Conoce a los artistas e instructores que harán de este festival una experiencia inolvidable.
|
||||
{t("staff.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -83,17 +85,23 @@ const StaffSection = () => {
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
className="flex flex-wrap gap-3 mb-8 justify-center md:justify-start"
|
||||
>
|
||||
{["All", "Instructors", "DJs"].map((role) => (
|
||||
{(
|
||||
[
|
||||
{ id: "all", labelKey: "staff.filters.all" },
|
||||
{ id: "instructors", labelKey: "staff.filters.instructors" },
|
||||
{ id: "djs", labelKey: "staff.filters.djs" },
|
||||
] as const
|
||||
).map(({ id, labelKey }) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => setFilter(role)}
|
||||
key={id}
|
||||
onClick={() => setFilter(id)}
|
||||
className={`px-6 py-2 rounded-full text-sm font-semibold transition-all duration-300 border ${
|
||||
filter === role
|
||||
filter === id
|
||||
? "bg-primary text-primary-foreground border-primary shadow-md"
|
||||
: "bg-card text-foreground border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{role === "All" ? "Todos" : role}
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -140,14 +148,26 @@ const StaffSection = () => {
|
||||
roleBadgeClass[member.role] || "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{member.role}
|
||||
{(() => {
|
||||
const roleLabelKey =
|
||||
member.role === "Instructor"
|
||||
? "instructor"
|
||||
: member.role === "DJ"
|
||||
? "dj"
|
||||
: member.role === "Organizador"
|
||||
? "organizer"
|
||||
: undefined;
|
||||
return roleLabelKey ? t(`staff.${roleLabelKey}`) : member.role;
|
||||
})()}
|
||||
</span>
|
||||
|
||||
<h3 className="font-display text-xl md:text-2xl lg:text-3xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-1">
|
||||
{member.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{member.description}
|
||||
{member.description?.trim().startsWith("[") && member.description?.trim().endsWith("]")
|
||||
? t("staff.placeholder")
|
||||
: member.description}
|
||||
</p>
|
||||
|
||||
{/* Redes sociales */}
|
||||
@@ -159,7 +179,7 @@ const StaffSection = () => {
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Instagram className="w-4 h-4" />
|
||||
Instagram
|
||||
{t("staff.socials.instagram")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user