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"
];

View File

@@ -28,7 +28,22 @@ const FloatingButton = () => {
className="animate-pulse-glow rounded-full px-6 shadow-elevated"
asChild
>
<a href="#booking">🎶 Reservar</a>
<a href="#booking" className="inline-flex items-center gap-2">
{/* Inline SVG: dancing couple icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
aria-hidden="true"
focusable="false"
>
<path d="M7.5 4.75a2.25 2.25 0 1 1 4.5 0 2.25 2.25 0 0 1-4.5 0Z" />
<path d="M4.25 20a.75.75 0 0 1-.743-.653l-.23-1.61a3.75 3.75 0 0 1 1.64-3.67l2.41-1.6a1.5 1.5 0 0 0 .67-1.1l.16-1.3a2.25 2.25 0 0 1 2.22-1.97h.2a2.25 2.25 0 0 1 1.98 1.19l.54 1.03 1.66.83a3 3 0 0 1 1.33 1.22l1.76 2.89c.23.38.09.87-.29 1.1a.8.8 0 0 1-1.08-.28l-1.47-2.42a1.5 1.5 0 0 0-.67-.61l-1.63-.81-.25 1.77a2.25 2.25 0 0 1-1.01 1.55l-2.98 2a2.25 2.25 0 0 0-.98 1.64l-.06.92A.75.75 0 0 1 6 20H4.25Z" />
<path d="M14.75 6.5a1 1 0 0 1 1.5-.86l2 .99a2.5 2.5 0 0 1 1.12 3.26l-.37.78a.75.75 0 1 1-1.36-.64l.37-.78a1 1 0 0 0-.45-1.3l-1.99-.99a1 1 0 0 1-.32-1.46Z" />
</svg>
Reservar
</a>
</Button>
<button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}

View File

@@ -98,7 +98,7 @@ const HeroSection = () => {
transition={{ delay: 1 }}
>
<Button variant="hero" size="lg" className="text-lg px-10 py-6" asChild>
<a href="#booking">Reservar Asiento</a>
<a href="#booking">Reservar tu pase</a>
</Button>
</motion.div>
</div>

View File

@@ -22,7 +22,7 @@ const Navbar = () => {
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? "bg-background/95 backdrop-blur-md shadow-card"
: "bg-transparent"
: "bg-white/30"
}`}
>
<div className="container mx-auto flex items-center justify-between px-4 py-3">

View File

@@ -10,15 +10,17 @@ const roleBadgeClass: Record<string, string> = {
};
const StaffSection = () => (
<section id="staff" className="section-padding bg-background">
<div className="container mx-auto">
<section id="staff" className="section-padding relative overflow-hidden">
{/* subtle radial background to match site accents */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-background to-background/60" />
<div className="relative container mx-auto w-[90%]">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<h2 className="font-display text-4xl md:text-5xl font-bold text-gradient mb-4">
<h2 className="font-display text-4xl md:text-5xl font-bold bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent mb-4">
Staff del Evento
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
@@ -26,53 +28,50 @@ const StaffSection = () => (
</p>
</motion.div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{STAFF.map((member, i) => (
<div className="mx-auto grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{STAFF.slice(0, 1).map((member, i) => (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="bg-card rounded-2xl overflow-hidden shadow-card hover:shadow-elevated transition-shadow group"
className="group rounded-2xl border border-border/50 bg-card/80 backdrop-blur supports-[backdrop-filter]:bg-card/60 shadow-sm hover:shadow-xl transition-all duration-300 mx-auto w-full max-w-xl p-5"
>
{/* Foto */}
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
<div className="relative aspect-square rounded-xl overflow-hidden bg-muted">
{member.image ? (
<img
src={member.image}
alt={member.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<User className="w-16 h-16 text-muted-foreground/40" />
<div className="w-full h-full flex items-center justify-center">
<User className="w-16 h-16 text-muted-foreground" />
</div>
)}
{/* top gradient sheen */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-background/40 to-transparent" />
</div>
<div className="p-5">
{/* Badge de rol */}
<span
className={`inline-block text-xs font-semibold px-3 py-1 rounded-full mb-3 ${
roleBadgeClass[member.role] || "bg-muted text-muted-foreground"
}`}
>
{member.role}
</span>
<h3 className="font-display text-lg font-bold text-foreground mb-2">
<div className="text-center mt-5">
<div className="mb-2 flex items-center justify-center gap-2">
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${roleBadgeClass[member.role] ?? "bg-muted text-muted-foreground"}`}>
{member.role}
</span>
</div>
<h3 className="text-xl font-semibold text-foreground mb-1 tracking-tight">
{member.name}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{member.description}
{member.subtitle ?? ""}
</p>
{/* Redes sociales */}
{member.socials?.instagram && (
{member.instagram && (
<a
href={member.socials.instagram}
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors"
className="inline-flex items-center gap-2 rounded-full border border-border px-4 py-2 text-sm text-foreground/90 hover:text-foreground hover:border-primary/50 hover:bg-primary/5 transition-colors"
>
<Instagram className="w-4 h-4" />
Instagram