mirror of
https://github.com/Ichitux/lambada-fiesta-live.git
synced 2026-05-15 15:12:19 +02:00
457 lines
20 KiB
TypeScript
457 lines
20 KiB
TypeScript
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"
|
||
];
|