Design modifications

This commit is contained in:
Antoni Nuñez Romeu
2026-03-11 18:23:04 +01:00
parent 36ac86f60e
commit a0855bb203
5 changed files with 322 additions and 88 deletions

View File

@@ -1,9 +1,9 @@
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { WEBHOOK_URL } from "@/data/event-data";
import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
import { CheckCircle, Loader2, AlertCircle, Search } from "lucide-react";
/**
* FORMULARIO DE RESERVA
@@ -15,29 +15,98 @@ import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
* 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),
seats: z.string().min(1, "Selecciona el número de plazas"),
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),
comment: z.string().max(500).optional(),
});
type BookingData = z.infer<typeof bookingSchema>;
const BookingSection = () => {
const [form, setForm] = useState<BookingData>({
name: "", email: "", seats: "1", country: "", comment: "",
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);
// 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;
setForm((prev) => ({ ...prev, [name]: value }));
// 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]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
@@ -100,10 +169,10 @@ const BookingSection = () => {
className="text-center mb-10"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
Reservar Asiento
Reserva tu pase
</h2>
<p className="text-muted-foreground">
Sin pago online. Solo reserva tu plaza y paga en el evento.
Selecciona tu pase y cantidad. Sin pago online, paga en el evento.
</p>
</motion.div>
@@ -114,17 +183,35 @@ const BookingSection = () => {
onSubmit={handleSubmit}
className="bg-card rounded-2xl p-6 md:p-10 shadow-elevated space-y-5"
>
{/* 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 completo"
/>
{errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>}
{/* 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 */}
@@ -141,50 +228,154 @@ const BookingSection = () => {
{errors.email && <p className="text-destructive text-xs mt-1">{errors.email}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Plazas */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Plazas *</label>
<select
name="seats"
value={form.seats}
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"
>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={String(n)}>{n}</option>
))}
</select>
{errors.seats && <p className="text-destructive text-xs mt-1">{errors.seats}</p>}
</div>
{/* País */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">País *</label>
<input
name="country"
value={form.country}
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="España"
/>
{/* 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>
{/* Comentarios */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Comentarios</label>
<textarea
name="comment"
value={form.comment}
onChange={handleChange}
rows={3}
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 resize-none"
placeholder="¿Algo que quieras comentarnos?"
/>
{/* 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" />
@@ -202,7 +393,7 @@ const BookingSection = () => {
{status === "loading" ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</>
) : (
"Reservar Asiento"
"Reservar Pass"
)}
</Button>
</motion.form>
@@ -212,3 +403,32 @@ const BookingSection = () => {
};
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"
];