- Paso intermedio /solicitud/[id]: el cliente elige llamada, WhatsApp o formulario (crearLead ahora redirige aquí, no a /fotos). - /formulario: FormularioZonas permite añadir varias zonas, cada una con tipo, m², acabado, notas y fotos; /fotos queda como redirect. - guardarDetallesYFotos: guarda fotos (antes, por zona) y notas (por zona), agrega los campos del lead (m² suma, tipo único o 'integral', calidad más alta, tasteText concatenado) para el presupuesto orientativo inmediato, y señala perfilCompleto al flujo externo. - Elimina FotosUploader (sustituido por FormularioZonas). Verificado en navegador: 2 zonas → presupuesto al instante + notas por zona + evento de perfil en DB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
465 lines
20 KiB
TypeScript
465 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect, FormEvent } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { crearLead } from '@/app/solicitud/actions';
|
|
|
|
type FormData = {
|
|
name: string;
|
|
email: string;
|
|
phone: string;
|
|
};
|
|
|
|
type FormErrors = Partial<Record<keyof FormData, string>>;
|
|
type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
|
|
const initialData: FormData = {
|
|
name: '',
|
|
email: '',
|
|
phone: '',
|
|
};
|
|
|
|
const initialConsents = {
|
|
privacy: false,
|
|
contracting: false,
|
|
};
|
|
|
|
function validateForm(data: FormData): FormErrors {
|
|
const errors: FormErrors = {};
|
|
if (!data.name.trim()) errors.name = 'El nombre es obligatorio';
|
|
if (!data.email.trim()) {
|
|
errors.email = 'El email es obligatorio';
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
errors.email = 'Introduce un email 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;
|
|
}
|
|
|
|
export default function ContactForm({ slug }: { slug: string }) {
|
|
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 [submitError, setSubmitError] = useState<string | null>(null);
|
|
const router = useRouter();
|
|
const sectionRef = 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 = sectionRef.current?.querySelectorAll('.reveal');
|
|
elements?.forEach((el) => observer.observe(el));
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
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>) => {
|
|
const { name } = e.target;
|
|
setTouched((prev) => ({ ...prev, [name]: true }));
|
|
const newErrors = validateForm(formData);
|
|
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
|
|
};
|
|
|
|
// El submit queda deshabilitado hasta que los dos consentimientos estén marcados (RF-B-04).
|
|
// La validación de campos se ejecuta on submit/blur para que los errores sean visibles.
|
|
const consentsGranted = consents.privacy && consents.contracting;
|
|
|
|
const handleSubmit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
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');
|
|
setSubmitError(null);
|
|
const result = await crearLead(slug, {
|
|
nombre: formData.name,
|
|
email: formData.email,
|
|
telefono: formData.phone,
|
|
consentPrivacidad: consents.privacy,
|
|
consentContratacion: consents.contracting,
|
|
});
|
|
if (!result.ok) {
|
|
setStatus('error');
|
|
setSubmitError(result.error);
|
|
return;
|
|
}
|
|
router.push(`/solicitud/${result.leadId}`);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setStatus('idle');
|
|
setFormData(initialData);
|
|
setConsents(initialConsents);
|
|
setErrors({});
|
|
setTouched({});
|
|
};
|
|
|
|
return (
|
|
<section
|
|
className="section bg-white"
|
|
id="contact"
|
|
ref={sectionRef}
|
|
aria-labelledby="contact-heading"
|
|
>
|
|
<div className="container">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-16 items-start">
|
|
{/* Left info panel */}
|
|
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col gap-6 lg:sticky lg:top-[104px]">
|
|
<div className="mb-2">
|
|
<span className="badge badge-dark">Empieza tu reforma</span>
|
|
</div>
|
|
<h2
|
|
id="contact-heading"
|
|
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.05] text-black"
|
|
>
|
|
Tu presupuesto,
|
|
<br />
|
|
en 5 minutos
|
|
</h2>
|
|
<p className="text-lg text-gray-600 leading-relaxed">
|
|
Déjanos tu teléfono y te llamamos en menos de 2 minutos. Te enviamos el render
|
|
y el presupuesto orientativo por WhatsApp.
|
|
</p>
|
|
|
|
{/* Contact details */}
|
|
<div className="flex flex-col gap-4 p-6 bg-gray-50 border border-gray-200 rounded-xl">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-10 h-10 bg-white border border-gray-200 rounded-lg flex items-center justify-center shrink-0 text-black">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
/>
|
|
<polyline points="22,6 12,13 2,6" stroke="currentColor" strokeWidth="2" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Email</div>
|
|
<div className="text-base font-semibold text-black">hola@reformix.es</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-10 h-10 bg-white border border-gray-200 rounded-lg flex items-center justify-center shrink-0 text-black">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.62a19.79 19.79 0 01-3.07-8.63A2 2 0 012.18 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.91 7.91a16 16 0 006.18 6.18l1.28-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Teléfono</div>
|
|
<div className="text-base font-semibold text-black">+34 900 123 456</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-10 h-10 bg-white border border-gray-200 rounded-lg flex items-center justify-center shrink-0 text-black">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
|
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="2" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Respuesta</div>
|
|
<div className="text-base font-semibold text-black">Llamada en < 2 min</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Testimonial */}
|
|
<blockquote className="bg-black text-white rounded-xl p-6 flex flex-col gap-4">
|
|
<p className="text-base leading-relaxed text-white/85 italic">
|
|
“Reformix me dio un presupuesto en 5 minutos. A la semana ya tenía a los operarios en casa. Mejor reforma que la que pedí.”
|
|
</p>
|
|
<footer className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-white/15 rounded-full flex items-center justify-center text-xs font-bold tracking-wider shrink-0">
|
|
LM
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-bold">Laura Martínez</div>
|
|
<div className="text-xs text-white/50">Reforma de cocina en Madrid</div>
|
|
</div>
|
|
</footer>
|
|
</blockquote>
|
|
</div>
|
|
|
|
{/* Form panel */}
|
|
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-150 bg-white border border-gray-200 rounded-2xl p-10 max-sm:p-6 shadow-lg">
|
|
{status === 'success' ? (
|
|
<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">
|
|
<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">
|
|
¡Te llamamos enseguida!
|
|
</h3>
|
|
<p className="text-base text-gray-600 max-w-[340px] leading-relaxed mb-4">
|
|
En menos de 2 minutos te llamamos al teléfono que nos has dejado.
|
|
Te enviaremos el render y el presupuesto por WhatsApp.
|
|
</p>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={handleReset}
|
|
id="contact-send-another-btn"
|
|
>
|
|
Pedir otro presupuesto
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<form
|
|
className="flex flex-col gap-5"
|
|
onSubmit={handleSubmit}
|
|
noValidate
|
|
aria-label="Formulario de captación de lead"
|
|
id="contact-form"
|
|
>
|
|
<div className="mb-2">
|
|
<h3 className="text-2xl font-extrabold tracking-tight text-black">
|
|
Pide tu presupuesto
|
|
</h3>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
Los 3 campos son obligatorios.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="contact-name" className="text-sm font-semibold text-dark">
|
|
Nombre <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
id="contact-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"
|
|
required
|
|
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" role="alert">
|
|
{errors.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="contact-email" className="text-sm font-semibold text-dark">
|
|
Email <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
id="contact-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@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-invalid={!!(errors.email && touched.email)}
|
|
/>
|
|
{errors.email && touched.email && (
|
|
<span id="email-error" className="text-xs text-error font-medium" role="alert">
|
|
{errors.email}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phone */}
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="contact-phone" className="text-sm font-semibold text-dark">
|
|
Teléfono <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
id="contact-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 ? 'phone-error' : undefined}
|
|
aria-invalid={!!(errors.phone && touched.phone)}
|
|
/>
|
|
{errors.phone && touched.phone && (
|
|
<span id="phone-error" className="text-xs text-error font-medium" role="alert">
|
|
{errors.phone}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Consents */}
|
|
<fieldset className="flex flex-col gap-3 mt-2 pt-4 border-t border-gray-200">
|
|
<legend className="sr-only">Consentimientos</legend>
|
|
|
|
<label
|
|
htmlFor="consent-privacy"
|
|
className="flex items-start gap-3 cursor-pointer text-sm text-gray-700 leading-relaxed"
|
|
>
|
|
<input
|
|
id="consent-privacy"
|
|
type="checkbox"
|
|
checked={consents.privacy}
|
|
onChange={(e) =>
|
|
setConsents((c) => ({ ...c, privacy: e.target.checked }))
|
|
}
|
|
className="mt-1 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="consent-contracting"
|
|
className="flex items-start gap-3 cursor-pointer text-sm text-gray-700 leading-relaxed"
|
|
>
|
|
<input
|
|
id="consent-contracting"
|
|
type="checkbox"
|
|
checked={consents.contracting}
|
|
onChange={(e) =>
|
|
setConsents((c) => ({ ...c, contracting: e.target.checked }))
|
|
}
|
|
className="mt-1 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>
|
|
|
|
{submitError && (
|
|
<p className="text-sm text-error font-medium" role="alert">
|
|
{submitError}
|
|
</p>
|
|
)}
|
|
|
|
{/* Submit */}
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary btn-lg w-full justify-center mt-2 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
|
|
disabled={status === 'loading' || !consentsGranted}
|
|
id="contact-submit-btn"
|
|
aria-busy={status === 'loading'}
|
|
aria-disabled={status === 'loading' || !consentsGranted}
|
|
>
|
|
{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...
|
|
</>
|
|
) : (
|
|
<>
|
|
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"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|