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 { motion } from "framer-motion";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { WEBHOOK_URL } from "@/data/event-data"; 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 * FORMULARIO DE RESERVA
@@ -15,29 +15,98 @@ import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
* 3. Conecta el webhook a Google Sheets / Airtable / Email * 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({ const bookingSchema = z.object({
requestId: z.string().optional(),
name: z.string().trim().min(2, "El nombre debe tener al menos 2 caracteres").max(100), 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), 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), country: z.string().trim().min(2, "Indica tu país").max(100),
comment: z.string().max(500).optional(),
}); });
type BookingData = z.infer<typeof bookingSchema>; type BookingData = z.infer<typeof bookingSchema>;
const BookingSection = () => { const BookingSection = () => {
const [form, setForm] = useState<BookingData>({ 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 [errors, setErrors] = useState<Partial<Record<keyof BookingData, string>>>({});
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); 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 handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target; 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 })); 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 })); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setErrors({}); setErrors({});
@@ -100,10 +169,10 @@ const BookingSection = () => {
className="text-center mb-10" className="text-center mb-10"
> >
<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 text-gradient mb-4">
Reservar Asiento Reserva tu pase
</h2> </h2>
<p className="text-muted-foreground"> <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> </p>
</motion.div> </motion.div>
@@ -114,6 +183,10 @@ const BookingSection = () => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-card rounded-2xl p-6 md:p-10 shadow-elevated space-y-5" 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 */} {/* Nombre */}
<div> <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">Nombre *</label>
@@ -122,11 +195,25 @@ const BookingSection = () => {
value={form.name} value={form.name}
onChange={handleChange} 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" 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" placeholder="Tu nombre"
/> />
{errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>} {errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>}
</div> </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 */} {/* Email */}
<div> <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">Email *</label>
@@ -141,49 +228,153 @@ const BookingSection = () => {
{errors.email && <p className="text-destructive text-xs mt-1">{errors.email}</p>} {errors.email && <p className="text-destructive text-xs mt-1">{errors.email}</p>}
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* País - Searchable Selector */}
{/* Plazas */} <div className="space-y-2">
<div> <label className="block text-sm font-medium text-foreground">País *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Plazas *</label> <div className="relative" ref={countryInputRef}>
<select <div className="relative">
name="seats" <input
value={form.seats} type="text"
onChange={handleChange} value={countrySearch}
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" onChange={(e) => {
> setCountrySearch(e.target.value);
{[1, 2, 3, 4, 5].map((n) => ( setShowCountryDropdown(true);
<option key={n} value={String(n)}>{n}</option> }}
))} onFocus={() => setShowCountryDropdown(true)}
</select> placeholder="Buscar país..."
{errors.seats && <p className="text-destructive text-xs mt-1">{errors.seats}</p>} 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> </div>
{/* País */} {showCountryDropdown && (
<div> <motion.div
<label className="block text-sm font-medium text-foreground mb-1.5">País *</label> ref={countryDropdownRef}
<input initial={{ opacity: 0, y: -10 }}
name="country" animate={{ opacity: 1, y: 0 }}
value={form.country} className="absolute z-10 mt-1 w-full rounded-lg border border-input bg-popover shadow-lg max-h-60 overflow-auto"
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" {filteredCountries.length > 0 ? (
placeholder="España" 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>} {errors.country && <p className="text-destructive text-xs mt-1">{errors.country}</p>}
</div> </div>
</div> </div>
{/* Comentarios */} {/* Tipo de Pass - Cards */}
<div> <div className="space-y-4">
<label className="block text-sm font-medium text-foreground mb-1.5">Comentarios</label> <label className="block text-sm font-medium text-foreground mb-3">Tipo de Pass *</label>
<textarea <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
name="comment" {PASS_TYPES.map((pass) => (
value={form.comment} <motion.div
onChange={handleChange} key={pass.id}
rows={3} whileHover={{ scale: 1.02 }}
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" whileTap={{ scale: 0.98 }}
placeholder="¿Algo que quieras comentarnos?" 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> </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" && ( {status === "error" && (
<div className="flex items-center gap-2 text-destructive text-sm bg-destructive/10 rounded-lg p-3"> <div className="flex items-center gap-2 text-destructive text-sm bg-destructive/10 rounded-lg p-3">
@@ -202,7 +393,7 @@ const BookingSection = () => {
{status === "loading" ? ( {status === "loading" ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</> <><Loader2 className="w-4 h-4 animate-spin" /> Enviando...</>
) : ( ) : (
"Reservar Asiento" "Reservar Pass"
)} )}
</Button> </Button>
</motion.form> </motion.form>
@@ -212,3 +403,32 @@ const BookingSection = () => {
}; };
export default 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" className="animate-pulse-glow rounded-full px-6 shadow-elevated"
asChild 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>
<button <button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}

View File

@@ -98,7 +98,7 @@ const HeroSection = () => {
transition={{ delay: 1 }} transition={{ delay: 1 }}
> >
<Button variant="hero" size="lg" className="text-lg px-10 py-6" asChild> <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> </Button>
</motion.div> </motion.div>
</div> </div>

View File

@@ -22,7 +22,7 @@ const Navbar = () => {
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${ className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled scrolled
? "bg-background/95 backdrop-blur-md shadow-card" ? "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"> <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 = () => ( const StaffSection = () => (
<section id="staff" className="section-padding bg-background"> <section id="staff" className="section-padding relative overflow-hidden">
<div className="container mx-auto"> {/* 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 <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
className="text-center mb-12" 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 Staff del Evento
</h2> </h2>
<p className="text-muted-foreground max-w-2xl mx-auto"> <p className="text-muted-foreground max-w-2xl mx-auto">
@@ -26,53 +28,50 @@ const StaffSection = () => (
</p> </p>
</motion.div> </motion.div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="mx-auto grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{STAFF.map((member, i) => ( {STAFF.slice(0, 1).map((member, i) => (
<motion.div <motion.div
key={member.id} key={member.id}
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: i * 0.1 }} 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 */} {/* 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 ? ( {member.image ? (
<img <img
src={member.image} src={member.image}
alt={member.name} 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> </div>
)}
<div className="p-5"> {/* top gradient sheen */}
{/* Badge de rol */} <div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-background/40 to-transparent" />
<span </div>
className={`inline-block text-xs font-semibold px-3 py-1 rounded-full mb-3 ${ <div className="text-center mt-5">
roleBadgeClass[member.role] || "bg-muted text-muted-foreground" <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} {member.role}
</span> </span>
</div>
<h3 className="font-display text-lg font-bold text-foreground mb-2"> <h3 className="text-xl font-semibold text-foreground mb-1 tracking-tight">
{member.name} {member.name}
</h3> </h3>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{member.description} {member.subtitle ?? ""}
</p> </p>
{member.instagram && (
{/* Redes sociales */}
{member.socials?.instagram && (
<a <a
href={member.socials.instagram} href={member.instagram}
target="_blank" target="_blank"
rel="noopener noreferrer" 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 className="w-4 h-4" />
Instagram Instagram