Actualización de título y adición de favicon en landing B2B

This commit is contained in:
Carlos Narro
2026-05-28 22:55:33 +02:00
parent aa7555b49d
commit 9020c24e68
18 changed files with 497 additions and 630 deletions

View File

@@ -5,9 +5,7 @@ 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>>;
@@ -16,35 +14,38 @@ type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
const initialData: FormData = {
name: '',
email: '',
company: '',
phone: '',
message: '',
};
const initialConsents = {
privacy: false,
contracting: false,
};
function validateForm(data: FormData): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) errors.name = 'El nombre es requerido';
if (!data.name.trim()) errors.name = 'El nombre es obligatorio';
if (!data.email.trim()) {
errors.email = 'El email es requerido';
errors.email = 'El email es obligatorio';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = 'Ingresa un email válido';
errors.email = 'Introduce 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';
if (!data.phone.trim()) {
errors.phone = 'El teléfono es obligatorio';
} else if (!/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
errors.phone = 'Introduce un teléfono válido';
}
return errors;
}
function LeadForm() {
const [formData, setFormData] = useState<FormData>(initialData);
const [consents, setConsents] = useState(initialConsents);
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (touched[name as keyof FormData]) {
@@ -53,33 +54,30 @@ function LeadForm() {
}
};
const handleBlur = (
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
const newErrors = validateForm(formData);
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
};
const consentsGranted = consents.privacy && consents.contracting;
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);
setTouched({ name: true, email: true, phone: true });
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
if (!consentsGranted) return;
setStatus('loading');
await new Promise((resolve) => setTimeout(resolve, 1800));
await new Promise((resolve) => setTimeout(resolve, 1500));
setStatus('success');
setFormData(initialData);
setConsents(initialConsents);
setTouched({});
setErrors({});
};
@@ -87,6 +85,7 @@ function LeadForm() {
const handleReset = () => {
setStatus('idle');
setFormData(initialData);
setConsents(initialConsents);
setErrors({});
setTouched({});
};
@@ -94,12 +93,12 @@ function LeadForm() {
if (status === 'success') {
return (
<div
className="flex flex-col items-center justify-center text-center gap-4 py-16 px-8 animate-scaleIn"
className="flex flex-col items-center justify-center text-center gap-4 py-10 px-4 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">
<div className="w-16 h-16 bg-black text-white rounded-full flex items-center justify-center mb-2">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M6 16l7 7L26 9"
stroke="currentColor"
@@ -109,17 +108,18 @@ function LeadForm() {
/>
</svg>
</div>
<h3 className="text-2xl font-extrabold tracking-tight text-black">
¡Mensaje enviado!
<h3 className="text-xl font-extrabold tracking-tight text-black">
¡Te llamamos enseguida!
</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 className="text-sm text-gray-600 max-w-[300px] leading-relaxed">
En menos de 2 minutos te llamamos al teléfono que nos has dejado.
Tendrás el render y el presupuesto en tu WhatsApp.
</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"
className="text-sm font-semibold text-black underline underline-offset-2 hover:no-underline mt-2"
onClick={handleReset}
>
Enviar otro mensaje
Pedir otro presupuesto
</button>
</div>
);
@@ -127,15 +127,16 @@ function LeadForm() {
return (
<form
className="flex flex-col gap-5"
className="flex flex-col gap-4"
onSubmit={handleSubmit}
noValidate
aria-label="Formulario de contacto"
aria-label="Formulario de captación de lead"
>
{/* Name + Email */}
<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>
Nombre <span className="text-error">*</span>
</label>
<input
id="lead-name"
@@ -150,12 +151,13 @@ function LeadForm() {
onChange={handleChange}
onBlur={handleBlur}
autoComplete="name"
required
aria-required="true"
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
aria-describedby={errors.name && touched.name ? 'lead-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">
<span id="lead-name-error" className="text-xs text-error font-medium" role="alert">
{errors.name}
</span>
)}
@@ -173,85 +175,111 @@ function LeadForm() {
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="juan@empresa.com"
placeholder="juan@email.com"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="email"
required
aria-required="true"
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
aria-describedby={errors.email && touched.email ? 'lead-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">
<span id="lead-email-error" className="text-xs text-error font-medium" 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>
{/* Phone */}
<div className="flex flex-col gap-2">
<label htmlFor="lead-phone" className="text-sm font-semibold text-dark">
Teléfono <span className="text-error">*</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="+34 612 345 678"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="tel"
inputMode="tel"
required
aria-required="true"
aria-describedby={errors.phone && touched.phone ? 'lead-phone-error' : undefined}
aria-invalid={!!(errors.phone && touched.phone)}
/>
{errors.phone && touched.phone && (
<span id="lead-phone-error" className="text-xs text-error font-medium" role="alert">
{errors.phone}
</span>
)}
</div>
{/* Consents */}
<fieldset className="flex flex-col gap-2.5 mt-1 pt-4 border-t border-gray-100">
<legend className="sr-only">Consentimientos</legend>
<label
htmlFor="lead-consent-privacy"
className="flex items-start gap-2.5 cursor-pointer text-xs text-gray-600 leading-relaxed"
>
<input
id="lead-consent-privacy"
type="checkbox"
checked={consents.privacy}
onChange={(e) => setConsents((c) => ({ ...c, privacy: e.target.checked }))}
className="mt-0.5 w-4 h-4 accent-black shrink-0 cursor-pointer"
required
aria-required="true"
/>
<span>
He leído y acepto la{' '}
<a href="#" className="text-black underline underline-offset-2 hover:no-underline">
política de privacidad
</a>
.
</span>
</label>
<label
htmlFor="lead-consent-contracting"
className="flex items-start gap-2.5 cursor-pointer text-xs text-gray-600 leading-relaxed"
>
<input
id="lead-consent-contracting"
type="checkbox"
checked={consents.contracting}
onChange={(e) => setConsents((c) => ({ ...c, contracting: e.target.checked }))}
className="mt-0.5 w-4 h-4 accent-black shrink-0 cursor-pointer"
required
aria-required="true"
/>
<span>
He leído y acepto las{' '}
<a href="#" className="text-black underline underline-offset-2 hover:no-underline">
condiciones de contratación
</a>
.
</span>
</label>
</fieldset>
{/* Submit */}
<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'}
className="btn btn-primary w-full justify-center mt-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
disabled={status === 'loading' || !consentsGranted}
aria-busy={status === 'loading'}
aria-disabled={status === 'loading' || !consentsGranted}
>
{status === 'loading' ? (
<>
@@ -263,7 +291,7 @@ function LeadForm() {
</>
) : (
<>
Enviar mensaje
Pedir presupuesto
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2 8h12M10 4l4 4-4 4"
@@ -276,14 +304,6 @@ function LeadForm() {
</>
)}
</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>
);
}
@@ -310,14 +330,14 @@ export default function Hero() {
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">
<div className="container pt-12 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">
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 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">
<h1 className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out text-[clamp(2.25rem,5vw,4rem)] font-black tracking-[-0.04em] leading-[1.05] text-black">
Tu reforma,
<br />
<em className="italic font-black">presupuestada</em>
@@ -325,44 +345,45 @@ export default function Hero() {
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">
<p className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100 text-base sm: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">
{/* CTAs */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-200 flex flex-col sm:flex-row gap-3">
<button
className="btn btn-primary btn-lg"
className="btn btn-primary btn-lg w-full sm:w-auto"
onClick={() => document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' })}
>
Empieza gratis
Calcular mi reforma 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' })}
className="btn btn-secondary btn-lg w-full sm:w-auto"
onClick={() => document.querySelector('#ver-reforma')?.scrollIntoView({ behavior: 'smooth' })}
>
Ver características
Ver una reforma
</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 className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-150 border border-gray-100 rounded-xl p-6 md:p-8 bg-white shadow-sm">
<div className="mb-5 md:mb-6">
<h2 className="text-xl font-black tracking-tight text-black">Pide tu presupuesto</h2>
<p className="text-sm text-gray-400 mt-1">En menos de 2 minutos te llamamos · Render por WhatsApp</p>
</div>
<LeadForm />
</div>
</div>
<hr className="border-gray-300 mt-12 pb-8" />
<hr className="border-gray-300 mt-12 md:mt-16 mb-8" />
{/* Servicios */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 justify-center">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-8 sm:gap-6 lg:gap-8 justify-center">
{[
{
icon: (
@@ -397,7 +418,7 @@ export default function Hero() {
<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>
<h3 className="text-lg font-black tracking-tight text-black">{title}</h3>
<p className="text-gray-400 leading-relaxed text-sm max-w-[280px]">{description}</p>
</div>
))}
@@ -406,4 +427,4 @@ export default function Hero() {
</div>
</section>
);
}
}