409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
type FormData = {
|
|
name: string;
|
|
email: string;
|
|
company: string;
|
|
phone: string;
|
|
message: string;
|
|
};
|
|
|
|
type FormErrors = Partial<Record<keyof FormData, string>>;
|
|
type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
|
|
const initialData: FormData = {
|
|
name: '',
|
|
email: '',
|
|
company: '',
|
|
phone: '',
|
|
message: '',
|
|
};
|
|
|
|
function validateForm(data: FormData): FormErrors {
|
|
const errors: FormErrors = {};
|
|
if (!data.name.trim()) errors.name = 'El nombre es requerido';
|
|
if (!data.email.trim()) {
|
|
errors.email = 'El email es requerido';
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
errors.email = 'Ingresa un email válido';
|
|
}
|
|
if (!data.company.trim()) errors.company = 'La empresa es requerida';
|
|
if (data.phone && !/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
|
|
errors.phone = 'Ingresa un teléfono válido';
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function LeadForm() {
|
|
const [formData, setFormData] = useState<FormData>(initialData);
|
|
const [errors, setErrors] = useState<FormErrors>({});
|
|
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
|
|
const [status, setStatus] = useState<SubmitStatus>('idle');
|
|
|
|
const handleChange = (
|
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
) => {
|
|
const { name, value } = e.target;
|
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
if (touched[name as keyof FormData]) {
|
|
const newErrors = validateForm({ ...formData, [name]: value });
|
|
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
|
|
}
|
|
};
|
|
|
|
const handleBlur = (
|
|
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
) => {
|
|
const { name } = e.target;
|
|
setTouched((prev) => ({ ...prev, [name]: true }));
|
|
const newErrors = validateForm(formData);
|
|
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const allTouched = Object.keys(formData).reduce(
|
|
(acc, k) => ({ ...acc, [k]: true }),
|
|
{} as Record<keyof FormData, boolean>
|
|
);
|
|
setTouched(allTouched);
|
|
|
|
const validationErrors = validateForm(formData);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
setErrors(validationErrors);
|
|
return;
|
|
}
|
|
|
|
setStatus('loading');
|
|
await new Promise((resolve) => setTimeout(resolve, 1800));
|
|
setStatus('success');
|
|
setFormData(initialData);
|
|
setTouched({});
|
|
setErrors({});
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setStatus('idle');
|
|
setFormData(initialData);
|
|
setErrors({});
|
|
setTouched({});
|
|
};
|
|
|
|
if (status === 'success') {
|
|
return (
|
|
<div
|
|
className="flex flex-col items-center justify-center text-center gap-4 py-16 px-8 animate-scaleIn"
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
<div className="w-18 h-18 bg-black text-white rounded-full flex items-center justify-center mb-2 p-4">
|
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M6 16l7 7L26 9"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-2xl font-extrabold tracking-tight text-black">
|
|
¡Mensaje enviado!
|
|
</h3>
|
|
<p className="text-base text-gray-600 max-w-[320px] leading-relaxed mb-4">
|
|
Gracias por contactarnos. Nuestro equipo te responderá en menos de 24 horas.
|
|
</p>
|
|
<button
|
|
className="btn btn-secondary text-sm px-4 py-2 bg-gray-100 hover:bg-gray-200 text-black font-semibold rounded-lg transition-colors"
|
|
onClick={handleReset}
|
|
>
|
|
Enviar otro mensaje
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form
|
|
className="flex flex-col gap-5"
|
|
onSubmit={handleSubmit}
|
|
noValidate
|
|
aria-label="Formulario de contacto"
|
|
>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="lead-name" className="text-sm font-semibold text-dark">
|
|
Nombre completo <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
id="lead-name"
|
|
name="name"
|
|
type="text"
|
|
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.name && touched.name
|
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
|
: 'border-gray-200'
|
|
}`}
|
|
placeholder="Juan García"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
autoComplete="name"
|
|
aria-required="true"
|
|
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
|
|
aria-invalid={!!(errors.name && touched.name)}
|
|
/>
|
|
{errors.name && touched.name && (
|
|
<span id="name-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
|
|
{errors.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="lead-email" className="text-sm font-semibold text-dark">
|
|
Email <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
id="lead-email"
|
|
name="email"
|
|
type="email"
|
|
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.email && touched.email
|
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
|
: 'border-gray-200'
|
|
}`}
|
|
placeholder="juan@empresa.com"
|
|
value={formData.email}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
autoComplete="email"
|
|
aria-required="true"
|
|
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
|
|
aria-invalid={!!(errors.email && touched.email)}
|
|
/>
|
|
{errors.email && touched.email && (
|
|
<span id="email-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
|
|
{errors.email}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="lead-company" className="text-sm font-semibold text-dark">
|
|
Empresa <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
id="lead-company"
|
|
name="company"
|
|
type="text"
|
|
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.company && touched.company
|
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
|
: 'border-gray-200'
|
|
}`}
|
|
placeholder="Mi Empresa S.A."
|
|
value={formData.company}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
autoComplete="organization"
|
|
aria-required="true"
|
|
aria-describedby={errors.company && touched.company ? 'company-error' : undefined}
|
|
aria-invalid={!!(errors.company && touched.company)}
|
|
/>
|
|
{errors.company && touched.company && (
|
|
<span id="company-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
|
|
{errors.company}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="lead-phone" className="text-sm font-semibold text-dark">
|
|
Teléfono <span className="font-normal text-gray-400">(opcional)</span>
|
|
</label>
|
|
<input
|
|
id="lead-phone"
|
|
name="phone"
|
|
type="tel"
|
|
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.phone && touched.phone
|
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
|
: 'border-gray-200'
|
|
}`}
|
|
placeholder="+52 55 1234 5678"
|
|
value={formData.phone}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
autoComplete="tel"
|
|
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
|
|
aria-invalid={!!(errors.phone && touched.phone)}
|
|
/>
|
|
{errors.phone && touched.phone && (
|
|
<span id="phone-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
|
|
{errors.phone}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
className="w-full bg-black text-white py-4 text-sm font-medium rounded-lg transition-opacity disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 flex justify-center items-center gap-2 mt-2"
|
|
disabled={status === 'loading'}
|
|
aria-busy={status === 'loading'}
|
|
>
|
|
{status === 'loading' ? (
|
|
<>
|
|
<span
|
|
className="w-[18px] h-[18px] border-2 border-white/30 border-t-white rounded-full animate-[spin_0.7s_linear_infinite] shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
Enviando...
|
|
</>
|
|
) : (
|
|
<>
|
|
Enviar mensaje
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M2 8h12M10 4l4 4-4 4"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
<p className="text-xs text-gray-400 text-center leading-relaxed">
|
|
Al enviar, aceptas nuestra{' '}
|
|
<a href="#" className="text-gray-600 underline underline-offset-2 hover:text-black">
|
|
política de privacidad
|
|
</a>
|
|
.
|
|
</p>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function Hero() {
|
|
const heroRef = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('opacity-100', 'translate-y-0');
|
|
entry.target.classList.remove('opacity-0', 'translate-y-6');
|
|
}
|
|
});
|
|
},
|
|
{ threshold: 0.1 }
|
|
);
|
|
const elements = heroRef.current?.querySelectorAll('.reveal');
|
|
elements?.forEach((el) => observer.observe(el));
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<section className="bg-white overflow-hidden" id="hero" ref={heroRef} aria-label="Sección principal">
|
|
<div className="container py-16 md:pt-24 pb-8">
|
|
|
|
{/* Grid 2 columnas */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16 items-start">
|
|
|
|
{/* Columna izquierda — textos */}
|
|
<div className="flex flex-col gap-6">
|
|
<h1 className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out text-[clamp(2.5rem,5vw,4rem)] font-black tracking-[-0.04em] leading-[1.05] text-black">
|
|
Tu reforma,
|
|
<br />
|
|
<em className="italic font-black">presupuestada</em>
|
|
<br />
|
|
en 5 minutos.
|
|
</h1>
|
|
|
|
<p className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100 text-lg text-gray-500 leading-relaxed max-w-md">
|
|
Deja tu teléfono, sube una foto de tu cocina o baño y te llamamos desde tu provincia en menos de 2 minutos. Al colgar recibirás por WhatsApp el render de tu reforma + presupuesto desglosado.
|
|
</p>
|
|
|
|
{/* Stats */}
|
|
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-200 flex flex-wrap gap-3">
|
|
<button
|
|
className="btn btn-primary btn-lg"
|
|
onClick={() => document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' })}
|
|
>
|
|
Empieza gratis
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
className="btn btn-secondary btn-lg"
|
|
onClick={() => document.querySelector('#features')?.scrollIntoView({ behavior: 'smooth' })}
|
|
>
|
|
Ver características
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Columna derecha — formulario */}
|
|
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-150 border border-gray-100 rounded-xl p-8 bg-white shadow-sm">
|
|
<div className="mb-6">
|
|
<h2 className="text-xl font-black tracking-tight text-black">Recibe tu presupuesto gratis</h2>
|
|
<p className="text-sm text-gray-400 mt-1">Sin compromiso · En menos de 5 minutos</p>
|
|
</div>
|
|
<LeadForm />
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<hr className="border-gray-300 mt-12 pb-8" />
|
|
{/* Servicios */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 justify-center">
|
|
{[
|
|
{
|
|
icon: (
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
),
|
|
title: 'Reformas Integrales',
|
|
description: 'Gestionamos tu reforma de principio a fin con un objetivo claro: cumplir plazos y superar expectativas.',
|
|
},
|
|
{
|
|
icon: (
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
<polyline points="9 22 9 12 15 12 15 22" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
),
|
|
title: 'Reformas de Cocinas',
|
|
description: 'Transforma tu cocina en el espacio que siempre quisiste. Materiales de calidad, diseño a tu medida.',
|
|
},
|
|
{
|
|
icon: (
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
<path d="M4 12h16M4 12a2 2 0 01-2-2V6a2 2 0 012-2h16a2 2 0 012 2v4a2 2 0 01-2 2M4 12v6a2 2 0 002 2h12a2 2 0 002-2v-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
),
|
|
title: 'Reformas de Baños',
|
|
description: 'El baño es el espacio más personal del hogar. Te ayudamos a conseguir el resultado que mereces.',
|
|
},
|
|
].map(({ icon, title, description }) => (
|
|
<div key={title} className="flex flex-col gap-4 items-center text-center">
|
|
<div className="w-12 h-12 rounded-full bg-black flex items-center justify-center text-white">
|
|
{icon}
|
|
</div>
|
|
<h3 className="text-xl font-black tracking-tight text-black">{title}</h3>
|
|
<p className="text-gray-400 leading-relaxed text-sm max-w-[280px]">{description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
</div>
|
|
</section>
|
|
);
|
|
} |