Files
lambada-fiesta-live/src/components/BookingSection.tsx
Antoni Nuñez Romeu 5b663be89f
All checks were successful
Deploy NPM app / Deploy NPM (push) Successful in 52s
Mail & Fixes
2026-03-20 02:57:46 +01:00

457 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
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";
/**
* 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
*/
// 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 },
];
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>;
const BookingSection = () => {
const [form, setForm] = useState<BookingData>({
requestId: "", name: "", surname: "", email: "", passType: "", amount: "1", price: 0, country: "",
});
const [errors, setErrors] = useState<Partial<Record<keyof BookingData, string>>>({});
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [countrySearch, setCountrySearch] = useState("");
const [showCountryDropdown, setShowCountryDropdown] = useState(false);
const countryDropdownRef = useRef<HTMLDivElement>(null);
const countryInputRef = useRef<HTMLInputElement>(null);
const sectionRef = useRef<HTMLElement>(null);
// Generate unique requestId on component mount
useEffect(() => {
const uniqueId = `REQ-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
setForm(prev => ({ ...prev, requestId: uniqueId }));
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
// Calculate price when passType or amount changes
if (name === "passType" || name === "amount") {
// Update the form state first
setForm((prev) => ({ ...prev, [name]: value }));
// Get the updated form values
const updatedForm = { ...form, [name]: value };
// Calculate new price if both passType and amount are set
if ((updatedForm.passType || name === "passType") && (updatedForm.amount || name === "amount")) {
const selectedPass = PASS_TYPES.find(pass => pass.id === (name === "passType" ? value : updatedForm.passType));
if (selectedPass) {
const amountValue = name === "amount" ? parseInt(value) : parseInt(updatedForm.amount);
const newPrice = selectedPass.price * amountValue;
// Update the form with the calculated price
setForm((prev) => ({ ...prev, [name]: value, price: newPrice }));
}
}
} else {
setForm((prev) => ({ ...prev, [name]: value }));
}
setErrors((prev) => ({ ...prev, [name]: undefined }));
};
const handleCountrySelect = (country: string) => {
setForm((prev) => ({ ...prev, country }));
setCountrySearch(country);
setShowCountryDropdown(false);
setErrors((prev) => ({ ...prev, country: undefined }));
};
const filteredCountries = COUNTRIES.filter(country =>
country.toLowerCase().includes(countrySearch.toLowerCase())
).slice(0, 10); // Limit to 10 results for performance
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showCountryDropdown && countryDropdownRef.current && countryInputRef.current) {
const target = e.target as Node;
if (!countryDropdownRef.current.contains(target) && !countryInputRef.current.contains(target)) {
setShowCountryDropdown(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showCountryDropdown]);
// Ensure the booking section stays in view when status changes (e.g., after submit)
useEffect(() => {
if (status === "success" || status === "error") {
sectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [status]);
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 {
console.log("[Booking] Sending to:", WEBHOOK_URL);
const res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Secret": WEBHOOK_SECRET,
},
body: JSON.stringify(result.data),
});
console.log("[Booking] Response status:", res.status);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setStatus("success");
} catch (err) {
console.error("[Booking] Error:", err);
setStatus("error");
}
};
if (status === "success") {
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"
>
<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!
</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"
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" }}
>
<div className="container mx-auto max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.15 }}
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
</h2>
<p className="text-muted-foreground">
Selecciona tu pase y cantidad. Sin pago online, paga en el evento.
</p>
</motion.div>
<motion.form
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.15 }}
onSubmit={handleSubmit}
className="bg-card rounded-2xl p-6 md:p-10 shadow-elevated space-y-5"
>
{/* Request ID - Hidden field for internal tracking */}
<input type="hidden" name="requestId" value={form.requestId} onChange={handleChange} />
<div className="grid grid-cols-2 gap-4">
{/* 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"
/>
{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>
<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"
/>
{errors.surname && <p className="text-destructive text-xs mt-1">{errors.surname}</p>}
</div>
</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>
{/* País - Searchable Selector */}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">País *</label>
<div className="relative" ref={countryInputRef}>
<div className="relative">
<input
type="text"
value={countrySearch}
onChange={(e) => {
setCountrySearch(e.target.value);
setShowCountryDropdown(true);
}}
onFocus={() => setShowCountryDropdown(true)}
placeholder="Buscar país..."
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">
<Search className="h-4 w-4 text-muted-foreground" />
</div>
</div>
{showCountryDropdown && (
<motion.div
ref={countryDropdownRef}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute z-10 mt-1 w-full rounded-lg border border-input bg-popover shadow-lg max-h-60 overflow-auto"
>
{filteredCountries.length > 0 ? (
filteredCountries.map((country) => (
<div
key={country}
onClick={() => handleCountrySelect(country)}
className="px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer"
>
{country}
</div>
))
) : (
<div className="px-4 py-2 text-sm text-muted-foreground">
No se encontraron países
</div>
)}
</motion.div>
)}
{errors.country && <p className="text-destructive text-xs mt-1">{errors.country}</p>}
</div>
</div>
{/* Tipo de Pass - Cards */}
<div className="space-y-4">
<label className="block text-sm font-medium text-foreground mb-3">Tipo de Pass *</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PASS_TYPES.map((pass) => (
<motion.div
key={pass.id}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`cursor-pointer rounded-xl border-2 p-4 transition-all duration-200 ${
form.passType === pass.id
? "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)}
>
<div className="text-center">
<h3 className="font-semibold text-lg text-foreground mb-1">{pass.name}</h3>
<p className="text-2xl font-bold text-primary">{pass.price}</p>
</div>
</motion.div>
))}
</div>
{errors.passType && <p className="text-destructive text-xs mt-2">{errors.passType}</p>}
</div>
{/* Cantidad - Add/Subtract Buttons */}
<div className="space-y-3">
<label className="block text-sm font-medium text-foreground">Cantidad *</label>
<div className="flex items-center justify-center gap-4">
<motion.button
type="button"
whileHover={{ scale: form.passType ? 1.05 : 1 }}
whileTap={{ scale: form.passType ? 0.95 : 1 }}
onClick={() => {
if (!form.passType) return;
const currentAmount = parseInt(form.amount);
if (currentAmount > 1) {
handleChange({ target: { name: "amount", value: String(currentAmount - 1) } } as any);
}
}}
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
form.passType
? "bg-secondary hover:bg-secondary/80 text-foreground cursor-pointer"
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
disabled={!form.passType || parseInt(form.amount) <= 1}
>
</motion.button>
<div className={`w-16 h-12 rounded-lg border flex items-center justify-center text-lg font-semibold transition-colors ${
form.passType
? "bg-background border-input text-foreground"
: "bg-muted border-muted text-muted-foreground"
}`}>
{form.amount}
</div>
<motion.button
type="button"
whileHover={{ scale: form.passType ? 1.05 : 1 }}
whileTap={{ scale: form.passType ? 0.95 : 1 }}
onClick={() => {
if (!form.passType) return;
const currentAmount = parseInt(form.amount);
if (currentAmount < 10) {
handleChange({ target: { name: "amount", value: String(currentAmount + 1) } } as any);
}
}}
className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold transition-colors ${
form.passType
? "bg-secondary hover:bg-secondary/80 text-foreground cursor-pointer"
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
disabled={!form.passType || parseInt(form.amount) >= 10}
>
+
</motion.button>
</div>
{errors.amount && <p className="text-destructive text-xs text-center mt-2">{errors.amount}</p>}
</div>
{/* Precio Total - Beautiful bottom display */}
{form.price > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
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-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>
</div>
</motion.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 Pass"
)}
</Button>
</motion.form>
</div>
</section>
);
};
export default BookingSection;
// Country data for searchable selector
const COUNTRIES = [
"Afganistán", "Albania", "Alemania", "Andorra", "Angola", "Antigua y Barbuda", "Arabia Saudita", "Argelia",
"Argentina", "Armenia", "Australia", "Austria", "Azerbaiyán", "Bahamas", "Bangladés", "Barbados", "Baréin",
"Bélgica", "Belice", "Benín", "Bielorrusia", "Birmania", "Bolivia", "Bosnia y Herzegovina", "Botsuana",
"Brasil", "Brunéi", "Bulgaria", "Burkina Faso", "Burundi", "Bután", "Cabo Verde", "Camboya", "Camerún",
"Canadá", "Catar", "Chad", "Chile", "China", "Chipre", "Ciudad del Vaticano", "Colombia", "Comoras",
"Corea del Norte", "Corea del Sur", "Costa de Marfil", "Costa Rica", "Croacia", "Cuba", "Dinamarca",
"Dominica", "Ecuador", "Egipto", "El Salvador", "Emiratos Árabes Unidos", "Eritrea", "Eslovaquia",
"Eslovenia", "España", "Estados Unidos", "Estonia", "Etiopía", "Filipinas", "Finlandia", "Fiyi",
"Francia", "Gabón", "Gambia", "Georgia", "Ghana", "Granada", "Grecia", "Guatemala", "Guyana",
"Guinea", "Guinea-Bisáu", "Guinea Ecuatorial", "Haití", "Honduras", "Hungría", "India", "Indonesia",
"Irak", "Irán", "Irlanda", "Islandia", "Islas Marshall", "Islas Salomón", "Israel", "Italia",
"Jamaica", "Japón", "Jordania", "Kazajistán", "Kenia", "Kirguistán", "Kiribati", "Kuwait", "Laos",
"Lesoto", "Letonia", "Líbano", "Liberia", "Libia", "Liechtenstein", "Lituania", "Luxemburgo",
"Madagascar", "Malasia", "Malaui", "Maldivas", "Malí", "Malta", "Marruecos", "Mauricio", "Mauritania",
"México", "Micronesia", "Moldavia", "Mónaco", "Mongolia", "Montenegro", "Mozambique", "Namibia",
"Nauru", "Nepal", "Nicaragua", "Níger", "Nigeria", "Noruega", "Nueva Zelanda", "Omán", "Países Bajos",
"Pakistán", "Palaos", "Panamá", "Papúa Nueva Guinea", "Paraguay", "Perú", "Polonia", "Portugal",
"Reino Unido", "República Centroafricana", "República Checa", "República del Congo", "República Democrática del Congo",
"República Dominicana", "Ruanda", "Rumanía", "Rusia", "Samoa", "San Cristóbal y Nieves", "San Marino",
"San Vicente y las Granadinas", "Santa Lucía", "Santo Tomé y Príncipe", "Senegal", "Serbia",
"Seychelles", "Sierra Leona", "Singapur", "Siria", "Somalia", "Sri Lanka", "Suazilandia", "Sudáfrica",
"Sudán", "Sudán del Sur", "Suecia", "Suiza", "Surinam", "Tailandia", "Tanzania", "Tayikistán",
"Timor Oriental", "Togo", "Tonga", "Trinidad y Tobago", "Túnez", "Turkmenistán", "Turquía",
"Tuvalu", "Ucrania", "Uganda", "Uruguay", "Uzbekistán", "Vanuatu", "Venezuela", "Vietnam",
"Yemen", "Yibuti", "Zambia", "Zimbabue"
];