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

This commit is contained in:
Antoni Nuñez Romeu
2026-03-27 16:23:06 +01:00
parent 5b663be89f
commit 935921f698
22 changed files with 1220 additions and 509 deletions
+123 -42
View File
@@ -4,6 +4,9 @@ import { z } from "zod";
import { Button } from "@/components/ui/button";
import { WEBHOOK_URL, WEBHOOK_SECRET } from "@/data/event-data";
import { CheckCircle, Loader2, AlertCircle, Search } from "lucide-react";
import { EVENT_INFO } from "@/data/event-data";
import { getTimeLeft } from "@/components/HeroSection";
import { useTranslation } from "react-i18next";
/**
* FORMULARIO DE RESERVA
@@ -17,25 +20,25 @@ import { CheckCircle, Loader2, AlertCircle, Search } from "lucide-react";
// Define pass types with prices
const PASS_TYPES = [
{ id: "full", name: "Full Pass", price: 150 },
{ id: "party", name: "Party Pass", price: 80 },
{ id: "single", name: "Single Day Pass", price: 40 },
{ id: "full", price: 150 },
{ id: "party", price: 80 },
{ id: "single", price: 40 },
];
const bookingSchema = z.object({
requestId: z.string().optional(),
name: z.string().trim().min(2, "El nombre debe tener al menos 2 caracteres").max(100),
surname: z.string().trim().min(2, "El apellido debe tener al menos 2 caracteres").max(100),
email: z.string().trim().email("Email no válido").max(255),
passType: z.string().min(1, "Selecciona un tipo de pass"),
amount: z.string().min(1, "Selecciona la cantidad"),
price: z.number().optional(),
country: z.string().trim().min(2, "Indica tu país").max(100),
});
type BookingData = z.infer<typeof bookingSchema>;
type BookingData = {
requestId?: string;
name: string;
surname: string;
email: string;
passType: string;
amount: string;
price: number;
country: string;
};
const BookingSection = () => {
const { t, i18n } = useTranslation();
const [form, setForm] = useState<BookingData>({
requestId: "", name: "", surname: "", email: "", passType: "", amount: "1", price: 0, country: "",
});
@@ -46,6 +49,31 @@ const BookingSection = () => {
const countryDropdownRef = useRef<HTMLDivElement>(null);
const countryInputRef = useRef<HTMLInputElement>(null);
const sectionRef = useRef<HTMLElement>(null);
const bookingSchema = z.object({
requestId: z.string().optional(),
name: z.string().trim().min(2, i18n.t("booking.validation.nameMin")).max(100),
surname: z.string().trim().min(2, i18n.t("booking.validation.surnameMin")).max(100),
email: z.string().trim().email(i18n.t("booking.validation.emailInvalid")).max(255),
passType: z.string().min(1, i18n.t("booking.validation.passTypeRequired")),
amount: z.string().min(1, i18n.t("booking.validation.amountRequired")),
price: z.number().optional(),
country: z.string().trim().min(2, i18n.t("booking.validation.countryMin")).max(100),
});
const updatePassTypeOrAmount = (field: "passType" | "amount", value: string) => {
setForm((prev) => {
const updatedForm = { ...prev, [field]: value } as BookingData;
const selectedPass = PASS_TYPES.find((pass) => pass.id === (field === "passType" ? value : updatedForm.passType));
const amountValue = parseInt(updatedForm.amount);
const newPrice = selectedPass ? selectedPass.price * amountValue : updatedForm.price;
return { ...updatedForm, price: newPrice };
});
setErrors((prev) => ({ ...prev, [field]: undefined }));
};
// Generate unique requestId on component mount
useEffect(() => {
const uniqueId = `REQ-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
@@ -121,7 +149,7 @@ const BookingSection = () => {
const result = bookingSchema.safeParse(form);
if (!result.success) {
const fieldErrors: typeof errors = {};
const fieldErrors: Partial<Record<keyof BookingData, string>> = {};
result.error.errors.forEach((err) => {
const field = err.path[0] as keyof BookingData;
fieldErrors[field] = err.message;
@@ -151,6 +179,16 @@ const BookingSection = () => {
}
};
const [timeLeft, setTimeLeft] = useState(getTimeLeft(EVENT_INFO.date));
const isEventOngoing = timeLeft.days === 0 && timeLeft.hours === 0 && timeLeft.minutes === 0 && timeLeft.seconds === 0;
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(getTimeLeft(EVENT_INFO.date));
}, 1000);
return () => clearInterval(timer);
}, []);
if (status === "success") {
return (
<section
@@ -166,10 +204,35 @@ const BookingSection = () => {
>
<CheckCircle className="w-16 h-16 text-primary mx-auto mb-4" />
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
¡Reserva recibida!
{t("booking.status.successTitle")}
</h3>
<p className="text-muted-foreground">
Recibirás confirmación por email. ¡Nos vemos en la pista!
{t("booking.status.success")}
</p>
</motion.div>
</section>
);
}
if (isEventOngoing) {
return (
<section
id="booking"
ref={sectionRef}
className="section-padding bg-background scroll-mt-24 relative z-10 -mt-[40px] pt-[120px]"
style={{ borderRadius: "0 100% 0 0 / 0 120px 0 0" }}
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-lg mx-auto text-center bg-card rounded-2xl p-10 shadow-elevated"
>
<AlertCircle className="w-16 h-16 text-primary mx-auto mb-4" />
<h3 className="font-display text-2xl md:text-3xl lg:text-4xl pt-3 pb-5 leading-[1.6] font-bold text-foreground mb-2">
{t("booking.status.eventInProgressTitle")}
</h3>
<p className="text-muted-foreground">
{t("booking.status.eventInProgressDescription")}
</p>
</motion.div>
</section>
@@ -191,11 +254,9 @@ const BookingSection = () => {
className="text-center mb-10"
>
<h2 className="font-display text-4xl md:text-5xl lg:text-7xl break-words font-bold pt-4 pb-10 leading-[1.8] text-gradient">
Reserva tu pase
{t("booking.subtitle")}
</h2>
<p className="text-muted-foreground">
Selecciona tu pase y cantidad. Sin pago online, paga en el evento.
</p>
<p className="text-muted-foreground">{t("booking.description")}</p>
</motion.div>
<motion.form
@@ -211,26 +272,30 @@ const BookingSection = () => {
<div className="grid grid-cols-2 gap-4">
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Nombre *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">
{t("booking.formFields.name")} *
</label>
<input
name="name"
value={form.name}
onChange={handleChange}
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Tu nombre"
placeholder={t("booking.formFields.name")}
/>
{errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>}
</div>
{/* Apellido */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Apellido *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">
{t("booking.formFields.surname")} *
</label>
<input
name="surname"
value={form.surname}
onChange={handleChange}
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Tu apellido"
placeholder={t("booking.formFields.surname")}
/>
{errors.surname && <p className="text-destructive text-xs mt-1">{errors.surname}</p>}
</div>
@@ -238,21 +303,25 @@ const BookingSection = () => {
{/* Email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Email *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">
{t("booking.formFields.email")} *
</label>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="tu@email.com"
placeholder={t("booking.formFields.email")}
/>
{errors.email && <p className="text-destructive text-xs mt-1">{errors.email}</p>}
</div>
{/* País - Searchable Selector */}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">País *</label>
<label className="block text-sm font-medium text-foreground">
{t("booking.formFields.country")} *
</label>
<div className="relative" ref={countryInputRef}>
<div className="relative">
<input
@@ -263,7 +332,7 @@ const BookingSection = () => {
setShowCountryDropdown(true);
}}
onFocus={() => setShowCountryDropdown(true)}
placeholder="Buscar país..."
placeholder={t("booking.formFields.countryPlaceholder")}
className="w-full rounded-lg border border-input bg-background px-4 py-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring pr-10"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
@@ -290,7 +359,7 @@ const BookingSection = () => {
))
) : (
<div className="px-4 py-2 text-sm text-muted-foreground">
No se encontraron países
{t("booking.formFields.countryNoResults")}
</div>
)}
</motion.div>
@@ -302,7 +371,9 @@ const BookingSection = () => {
{/* Tipo de Pass - Cards */}
<div className="space-y-4">
<label className="block text-sm font-medium text-foreground mb-3">Tipo de Pass *</label>
<label className="block text-sm font-medium text-foreground mb-3">
{t("booking.formFields.passType")} *
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PASS_TYPES.map((pass) => (
<motion.div
@@ -314,10 +385,16 @@ const BookingSection = () => {
? "border-primary bg-primary/5 shadow-lg"
: "border-border hover:border-primary/50 hover:shadow-md"
}`}
onClick={() => handleChange({ target: { name: "passType", value: pass.id } } as any)}
onClick={() => updatePassTypeOrAmount("passType", pass.id)}
>
<div className="text-center">
<h3 className="font-semibold text-lg text-foreground mb-1">{pass.name}</h3>
<h3 className="font-semibold text-lg text-foreground mb-1">
{pass.id === "full"
? t("booking.fullPass")
: pass.id === "party"
? t("booking.partyPass")
: t("booking.singleDayPass")}
</h3>
<p className="text-2xl font-bold text-primary">{pass.price}€</p>
</div>
</motion.div>
@@ -328,7 +405,9 @@ const BookingSection = () => {
{/* Cantidad - Add/Subtract Buttons */}
<div className="space-y-3">
<label className="block text-sm font-medium text-foreground">Cantidad *</label>
<label className="block text-sm font-medium text-foreground">
{t("booking.quantity")} *
</label>
<div className="flex items-center justify-center gap-4">
<motion.button
type="button"
@@ -338,7 +417,7 @@ const BookingSection = () => {
if (!form.passType) return;
const currentAmount = parseInt(form.amount);
if (currentAmount > 1) {
handleChange({ target: { name: "amount", value: String(currentAmount - 1) } } as any);
updatePassTypeOrAmount("amount", String(currentAmount - 1));
}
}}
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
@@ -367,7 +446,7 @@ const BookingSection = () => {
if (!form.passType) return;
const currentAmount = parseInt(form.amount);
if (currentAmount < 10) {
handleChange({ target: { name: "amount", value: String(currentAmount + 1) } } as any);
updatePassTypeOrAmount("amount", String(currentAmount + 1));
}
}}
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
@@ -391,9 +470,9 @@ const BookingSection = () => {
className="bg-gradient-to-r from-primary/10 to-primary/5 rounded-xl p-6 border border-primary/20"
>
<div className="text-center">
<p className="text-sm text-muted-foreground mb-1">Precio Total</p>
<p className="text-sm text-muted-foreground mb-1">{t("booking.priceSummaryLabel")}</p>
<p className="text-3xl font-bold text-primary">{form.price.toFixed(2)}€</p>
<p className="text-xs text-muted-foreground mt-2">Sin pago online • Paga en el evento</p>
<p className="text-xs text-muted-foreground mt-2">{t("booking.priceSummaryNote")}</p>
</div>
</motion.div>
)}
@@ -401,7 +480,7 @@ const BookingSection = () => {
{status === "error" && (
<div className="flex items-center gap-2 text-destructive text-sm bg-destructive/10 rounded-lg p-3">
<AlertCircle className="w-4 h-4" />
Error al enviar. Inténtalo de nuevo.
{t("booking.status.error")}
</div>
)}
@@ -413,9 +492,11 @@ const BookingSection = () => {
disabled={status === "loading"}
>
{status === "loading" ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</>
<>
<Loader2 className="w-4 h-4 animate-spin" /> {t("booking.status.loading")}
</>
) : (
"Reservar Pass"
t("booking.buyButton")
)}
</Button>
</motion.form>