mirror of
https://github.com/Ichitux/lambada-fiesta-live.git
synced 2026-09-28 05:22:20 +00:00
Added multi-lingua, english-spanish. Timer conditions and Linting bugfixes.
Deploy NPM app / Deploy NPM (push) Successful in 29s
Deploy NPM app / Deploy NPM (push) Successful in 29s
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user