Actualización de título y adición de favicon en landing B2B
BIN
mvp/b2c/public/antes-bano.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
mvp/b2c/public/antes-comedor.webp
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
mvp/b2c/public/antes.webp
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
mvp/b2c/public/despues-bano.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
mvp/b2c/public/despues-comedor.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
mvp/b2c/public/despues.webp
Normal file
|
After Width: | Height: | Size: 153 KiB |
4
mvp/b2c/public/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#2F5C46"/>
|
||||||
|
<text x="32" y="48" text-anchor="middle" font-family="Georgia, 'Instrument Serif', 'Times New Roman', serif" font-style="italic" font-size="48" fill="#F6F4EF">R</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 317 B |
|
Before Width: | Height: | Size: 25 KiB |
@@ -2,13 +2,14 @@ import type { Metadata } from 'next';
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'FlowSync — El SaaS que impulsa tu equipo',
|
title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
|
||||||
description:
|
description:
|
||||||
'Automatiza flujos de trabajo, conecta equipos y escala tu negocio con FlowSync. La plataforma SaaS todo-en-uno para equipos modernos.',
|
'Sube fotos de tu cocina o baño y recibe un render con el presupuesto orientativo en minutos. Te llamamos en menos de 2.',
|
||||||
keywords: ['SaaS', 'productividad', 'automatización', 'equipos', 'gestión de proyectos'],
|
keywords: ['reforma', 'presupuesto reforma', 'render reforma', 'cocina', 'baño', 'reformistas'],
|
||||||
|
icons: { icon: '/icon.svg' },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'FlowSync — El SaaS que impulsa tu equipo',
|
title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
|
||||||
description: 'Automatiza flujos de trabajo y escala tu negocio con FlowSync.',
|
description: 'Render y presupuesto orientativo de tu reforma en minutos, por WhatsApp.',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Navbar from '@/components/Navbar/Navbar';
|
|||||||
import Hero from '@/components/Hero/Hero';
|
import Hero from '@/components/Hero/Hero';
|
||||||
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
||||||
import Features from '@/components/Features/Features';
|
import Features from '@/components/Features/Features';
|
||||||
import Pricing from '@/components/Pricing/Pricing';
|
import Testimonials from '@/components/Testimonials/Testimonials';
|
||||||
import ContactForm from '@/components/ContactForm/ContactForm';
|
import ContactForm from '@/components/ContactForm/ContactForm';
|
||||||
import Footer from '@/components/Footer/Footer';
|
import Footer from '@/components/Footer/Footer';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export default function Home() {
|
|||||||
<Hero />
|
<Hero />
|
||||||
<ReformaSlider />
|
<ReformaSlider />
|
||||||
<Features />
|
<Features />
|
||||||
<Pricing />
|
<Testimonials />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { useState, useRef, useEffect, FormEvent } from 'react';
|
|||||||
type FormData = {
|
type FormData = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
company: string;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
message: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormErrors = Partial<Record<keyof FormData, string>>;
|
type FormErrors = Partial<Record<keyof FormData, string>>;
|
||||||
@@ -16,33 +14,33 @@ type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
|
|||||||
const initialData: FormData = {
|
const initialData: FormData = {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
company: '',
|
|
||||||
phone: '',
|
phone: '',
|
||||||
message: '',
|
};
|
||||||
|
|
||||||
|
const initialConsents = {
|
||||||
|
privacy: false,
|
||||||
|
contracting: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function validateForm(data: FormData): FormErrors {
|
function validateForm(data: FormData): FormErrors {
|
||||||
const errors: 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()) {
|
if (!data.email.trim()) {
|
||||||
errors.email = 'El email es requerido';
|
errors.email = 'El email es obligatorio';
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
} 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.trim()) {
|
||||||
if (data.phone && !/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
|
errors.phone = 'El teléfono es obligatorio';
|
||||||
errors.phone = 'Ingresa un teléfono válido';
|
} else if (!/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
|
||||||
}
|
errors.phone = 'Introduce un teléfono válido';
|
||||||
if (!data.message.trim()) {
|
|
||||||
errors.message = 'El mensaje es requerido';
|
|
||||||
} else if (data.message.trim().length < 10) {
|
|
||||||
errors.message = 'El mensaje debe tener al menos 10 caracteres';
|
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const [formData, setFormData] = useState<FormData>(initialData);
|
const [formData, setFormData] = useState<FormData>(initialData);
|
||||||
|
const [consents, setConsents] = useState(initialConsents);
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
|
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
|
||||||
const [status, setStatus] = useState<SubmitStatus>('idle');
|
const [status, setStatus] = useState<SubmitStatus>('idle');
|
||||||
@@ -65,9 +63,7 @@ export default function ContactForm() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
if (touched[name as keyof FormData]) {
|
if (touched[name as keyof FormData]) {
|
||||||
@@ -76,34 +72,33 @@ export default function ContactForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = (
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
const { name } = e.target;
|
const { name } = e.target;
|
||||||
setTouched((prev) => ({ ...prev, [name]: true }));
|
setTouched((prev) => ({ ...prev, [name]: true }));
|
||||||
const newErrors = validateForm(formData);
|
const newErrors = validateForm(formData);
|
||||||
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof 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) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const allTouched = Object.keys(formData).reduce(
|
setTouched({ name: true, email: true, phone: true });
|
||||||
(acc, k) => ({ ...acc, [k]: true }),
|
|
||||||
{} as Record<keyof FormData, boolean>
|
|
||||||
);
|
|
||||||
setTouched(allTouched);
|
|
||||||
|
|
||||||
const validationErrors = validateForm(formData);
|
const validationErrors = validateForm(formData);
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!consentsGranted) return;
|
||||||
|
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
// Simulate API call
|
// TODO: integrar con backend captación (lead -> pending_photos). De momento mock.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setFormData(initialData);
|
setFormData(initialData);
|
||||||
|
setConsents(initialConsents);
|
||||||
setTouched({});
|
setTouched({});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
};
|
};
|
||||||
@@ -111,6 +106,7 @@ export default function ContactForm() {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setFormData(initialData);
|
setFormData(initialData);
|
||||||
|
setConsents(initialConsents);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setTouched({});
|
setTouched({});
|
||||||
};
|
};
|
||||||
@@ -127,18 +123,19 @@ export default function ContactForm() {
|
|||||||
{/* Left info panel */}
|
{/* 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="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">
|
<div className="mb-2">
|
||||||
<span className="badge badge-dark">Contacto</span>
|
<span className="badge badge-dark">Empieza tu reforma</span>
|
||||||
</div>
|
</div>
|
||||||
<h2
|
<h2
|
||||||
id="contact-heading"
|
id="contact-heading"
|
||||||
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.05] text-black"
|
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.05] text-black"
|
||||||
>
|
>
|
||||||
Hablemos de
|
Tu presupuesto,
|
||||||
<br />
|
<br />
|
||||||
tu reforma
|
en 5 minutos
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-600 leading-relaxed">
|
<p className="text-lg text-gray-600 leading-relaxed">
|
||||||
Cuéntanos qué tienes en mente. Un asesor de tu provincia te responderá en menos de 24 horas con una propuesta a medida.
|
Déjanos tu teléfono y te llamamos en menos de 2 minutos. Te enviamos el render
|
||||||
|
y el presupuesto orientativo por WhatsApp.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Contact details */}
|
{/* Contact details */}
|
||||||
@@ -185,7 +182,7 @@ export default function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Respuesta</div>
|
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Respuesta</div>
|
||||||
<div className="text-base font-semibold text-black">Menos de 24 horas</div>
|
<div className="text-base font-semibold text-black">Llamada en < 2 min</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,17 +224,18 @@ export default function ContactForm() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-extrabold tracking-tight text-black">
|
<h3 className="text-2xl font-extrabold tracking-tight text-black">
|
||||||
¡Mensaje enviado!
|
¡Te llamamos enseguida!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base text-gray-600 max-w-[320px] leading-relaxed mb-4">
|
<p className="text-base text-gray-600 max-w-[340px] leading-relaxed mb-4">
|
||||||
Gracias por contactarnos. Nuestro equipo te responderá en menos de 24 horas.
|
En menos de 2 minutos te llamamos al teléfono que nos has dejado.
|
||||||
|
Te enviaremos el render y el presupuesto por WhatsApp.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
id="contact-send-another-btn"
|
id="contact-send-another-btn"
|
||||||
>
|
>
|
||||||
Enviar otro mensaje
|
Pedir otro presupuesto
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -245,23 +243,22 @@ export default function ContactForm() {
|
|||||||
className="flex flex-col gap-5"
|
className="flex flex-col gap-5"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
aria-label="Formulario de contacto"
|
aria-label="Formulario de captación de lead"
|
||||||
id="contact-form"
|
id="contact-form"
|
||||||
>
|
>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<h3 className="text-2xl font-extrabold tracking-tight text-black">
|
<h3 className="text-2xl font-extrabold tracking-tight text-black">
|
||||||
Envíanos un mensaje
|
Pide tu presupuesto
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
Todos los campos marcados con * son requeridos
|
Los 3 campos son obligatorios.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row: Name + Email */}
|
{/* Name */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="contact-name" className="text-sm font-semibold text-dark">
|
<label htmlFor="contact-name" className="text-sm font-semibold text-dark">
|
||||||
Nombre completo <span className="text-error">*</span>
|
Nombre <span className="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="contact-name"
|
id="contact-name"
|
||||||
@@ -276,17 +273,19 @@ export default function ContactForm() {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
|
required
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
|
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
|
||||||
aria-invalid={!!(errors.name && touched.name)}
|
aria-invalid={!!(errors.name && touched.name)}
|
||||||
/>
|
/>
|
||||||
{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="name-error" className="text-xs text-error font-medium" role="alert">
|
||||||
{errors.name}
|
{errors.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="contact-email" className="text-sm font-semibold text-dark">
|
<label htmlFor="contact-email" className="text-sm font-semibold text-dark">
|
||||||
Email <span className="text-error">*</span>
|
Email <span className="text-error">*</span>
|
||||||
@@ -299,57 +298,27 @@ export default function ContactForm() {
|
|||||||
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
||||||
: 'border-gray-200'
|
: 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
placeholder="juan@empresa.com"
|
placeholder="juan@email.com"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
required
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
|
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
|
||||||
aria-invalid={!!(errors.email && touched.email)}
|
aria-invalid={!!(errors.email && touched.email)}
|
||||||
/>
|
/>
|
||||||
{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="email-error" className="text-xs text-error font-medium" role="alert">
|
||||||
{errors.email}
|
{errors.email}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row: Company + Phone */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="contact-company" className="text-sm font-semibold text-dark">
|
|
||||||
Empresa <span className="text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact-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>
|
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="contact-phone" className="text-sm font-semibold text-dark">
|
<label htmlFor="contact-phone" className="text-sm font-semibold text-dark">
|
||||||
Teléfono
|
Teléfono <span className="text-error">*</span>
|
||||||
<span className="font-normal text-gray-400"> (opcional)</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="contact-phone"
|
id="contact-phone"
|
||||||
@@ -359,64 +328,91 @@ export default function ContactForm() {
|
|||||||
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
||||||
: 'border-gray-200'
|
: 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
placeholder="+52 55 1234 5678"
|
placeholder="+34 612 345 678"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
|
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
|
||||||
aria-invalid={!!(errors.phone && touched.phone)}
|
aria-invalid={!!(errors.phone && touched.phone)}
|
||||||
/>
|
/>
|
||||||
{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">
|
<span id="phone-error" className="text-xs text-error font-medium" role="alert">
|
||||||
{errors.phone}
|
{errors.phone}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message */}
|
{/* Consents */}
|
||||||
<div className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-3 mt-2 pt-4 border-t border-gray-200">
|
||||||
<label htmlFor="contact-message" className="text-sm font-semibold text-dark">
|
<legend className="sr-only">Consentimientos</legend>
|
||||||
Mensaje <span className="text-error">*</span>
|
|
||||||
</label>
|
<label
|
||||||
<textarea
|
htmlFor="consent-privacy"
|
||||||
id="contact-message"
|
className="flex items-start gap-3 cursor-pointer text-sm text-gray-700 leading-relaxed"
|
||||||
name="message"
|
>
|
||||||
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)] resize-y min-h-[120px] ${errors.message && touched.message
|
<input
|
||||||
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
id="consent-privacy"
|
||||||
: 'border-gray-200'
|
type="checkbox"
|
||||||
}`}
|
checked={consents.privacy}
|
||||||
placeholder="Cuéntanos sobre tu reforma: qué espacio quieres reformar, cuál es tu presupuesto aproximado y en qué ciudad vives..."
|
onChange={(e) =>
|
||||||
rows={5}
|
setConsents((c) => ({ ...c, privacy: e.target.checked }))
|
||||||
value={formData.message}
|
}
|
||||||
onChange={handleChange}
|
className="mt-1 w-4 h-4 accent-black shrink-0 cursor-pointer"
|
||||||
onBlur={handleBlur}
|
required
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
aria-describedby={errors.message && touched.message ? 'message-error' : undefined}
|
|
||||||
aria-invalid={!!(errors.message && touched.message)}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center">
|
<span>
|
||||||
{errors.message && touched.message ? (
|
He leído y acepto la{' '}
|
||||||
<span id="message-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
|
<a
|
||||||
{errors.message}
|
href="#"
|
||||||
|
className="text-black underline underline-offset-2 hover:no-underline"
|
||||||
|
>
|
||||||
|
política de privacidad
|
||||||
|
</a>
|
||||||
|
.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
</label>
|
||||||
<span />
|
|
||||||
)}
|
<label
|
||||||
<span className="text-xs text-gray-400 ml-auto">
|
htmlFor="consent-contracting"
|
||||||
{formData.message.length} caracteres
|
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>
|
</span>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary btn-lg w-full justify-center mt-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
|
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'}
|
disabled={status === 'loading' || !consentsGranted}
|
||||||
id="contact-submit-btn"
|
id="contact-submit-btn"
|
||||||
aria-busy={status === 'loading'}
|
aria-busy={status === 'loading'}
|
||||||
|
aria-disabled={status === 'loading' || !consentsGranted}
|
||||||
>
|
>
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<>
|
<>
|
||||||
@@ -428,7 +424,7 @@ export default function ContactForm() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Enviar mensaje
|
Pedir presupuesto
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M2 8h12M10 4l4 4-4 4"
|
d="M2 8h12M10 4l4 4-4 4"
|
||||||
@@ -441,14 +437,6 @@ export default function ContactForm() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 transition-colors duration-150 hover:text-black">
|
|
||||||
política de privacidad
|
|
||||||
</a>
|
|
||||||
. Nunca compartiremos tu información.
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function Features() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="bg-gray-50 border-y border-gray-200 py-24"
|
className="bg-gray-50 border-y border-gray-200 py-16 md:py-24"
|
||||||
id="features"
|
id="features"
|
||||||
ref={sectionRef}
|
ref={sectionRef}
|
||||||
aria-labelledby="features-heading"
|
aria-labelledby="features-heading"
|
||||||
@@ -100,7 +100,7 @@ export default function Features() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6">
|
||||||
{features.map((feature, i) => (
|
{features.map((feature, i) => (
|
||||||
<article
|
<article
|
||||||
key={feature.title}
|
key={feature.title}
|
||||||
@@ -112,7 +112,7 @@ export default function Features() {
|
|||||||
{feature.icon}
|
{feature.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="text-[6rem] font-black leading-none text-gray-300 select-none -mt-2 -mb-2">
|
<div className="text-[4rem] sm:text-[6rem] font-black leading-none text-gray-300 select-none -mt-2 -mb-2">
|
||||||
{feature.tag}
|
{feature.tag}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-extrabold tracking-tight text-black leading-tight">
|
<h3 className="text-xl font-extrabold tracking-tight text-black leading-tight">
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
type FormData = {
|
type FormData = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
company: string;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
message: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormErrors = Partial<Record<keyof FormData, string>>;
|
type FormErrors = Partial<Record<keyof FormData, string>>;
|
||||||
@@ -16,35 +14,38 @@ type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
|
|||||||
const initialData: FormData = {
|
const initialData: FormData = {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
company: '',
|
|
||||||
phone: '',
|
phone: '',
|
||||||
message: '',
|
};
|
||||||
|
|
||||||
|
const initialConsents = {
|
||||||
|
privacy: false,
|
||||||
|
contracting: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function validateForm(data: FormData): FormErrors {
|
function validateForm(data: FormData): FormErrors {
|
||||||
const errors: 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()) {
|
if (!data.email.trim()) {
|
||||||
errors.email = 'El email es requerido';
|
errors.email = 'El email es obligatorio';
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
} 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.trim()) {
|
||||||
if (data.phone && !/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
|
errors.phone = 'El teléfono es obligatorio';
|
||||||
errors.phone = 'Ingresa un teléfono válido';
|
} else if (!/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
|
||||||
|
errors.phone = 'Introduce un teléfono válido';
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeadForm() {
|
function LeadForm() {
|
||||||
const [formData, setFormData] = useState<FormData>(initialData);
|
const [formData, setFormData] = useState<FormData>(initialData);
|
||||||
|
const [consents, setConsents] = useState(initialConsents);
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
|
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
|
||||||
const [status, setStatus] = useState<SubmitStatus>('idle');
|
const [status, setStatus] = useState<SubmitStatus>('idle');
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
if (touched[name as keyof FormData]) {
|
if (touched[name as keyof FormData]) {
|
||||||
@@ -53,33 +54,30 @@ function LeadForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = (
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
const { name } = e.target;
|
const { name } = e.target;
|
||||||
setTouched((prev) => ({ ...prev, [name]: true }));
|
setTouched((prev) => ({ ...prev, [name]: true }));
|
||||||
const newErrors = validateForm(formData);
|
const newErrors = validateForm(formData);
|
||||||
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
|
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const consentsGranted = consents.privacy && consents.contracting;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const allTouched = Object.keys(formData).reduce(
|
setTouched({ name: true, email: true, phone: true });
|
||||||
(acc, k) => ({ ...acc, [k]: true }),
|
|
||||||
{} as Record<keyof FormData, boolean>
|
|
||||||
);
|
|
||||||
setTouched(allTouched);
|
|
||||||
|
|
||||||
const validationErrors = validateForm(formData);
|
const validationErrors = validateForm(formData);
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!consentsGranted) return;
|
||||||
|
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setFormData(initialData);
|
setFormData(initialData);
|
||||||
|
setConsents(initialConsents);
|
||||||
setTouched({});
|
setTouched({});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
};
|
};
|
||||||
@@ -87,6 +85,7 @@ function LeadForm() {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setFormData(initialData);
|
setFormData(initialData);
|
||||||
|
setConsents(initialConsents);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setTouched({});
|
setTouched({});
|
||||||
};
|
};
|
||||||
@@ -94,12 +93,12 @@ function LeadForm() {
|
|||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div className="w-18 h-18 bg-black text-white rounded-full flex items-center justify-center mb-2 p-4">
|
<div className="w-16 h-16 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">
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M6 16l7 7L26 9"
|
d="M6 16l7 7L26 9"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -109,17 +108,18 @@ function LeadForm() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-extrabold tracking-tight text-black">
|
<h3 className="text-xl font-extrabold tracking-tight text-black">
|
||||||
¡Mensaje enviado!
|
¡Te llamamos enseguida!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base text-gray-600 max-w-[320px] leading-relaxed mb-4">
|
<p className="text-sm text-gray-600 max-w-[300px] leading-relaxed">
|
||||||
Gracias por contactarnos. Nuestro equipo te responderá en menos de 24 horas.
|
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>
|
</p>
|
||||||
<button
|
<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}
|
onClick={handleReset}
|
||||||
>
|
>
|
||||||
Enviar otro mensaje
|
Pedir otro presupuesto
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -127,15 +127,16 @@ function LeadForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-5"
|
className="flex flex-col gap-4"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="lead-name" className="text-sm font-semibold text-dark">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="lead-name"
|
id="lead-name"
|
||||||
@@ -150,12 +151,13 @@ function LeadForm() {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
|
required
|
||||||
aria-required="true"
|
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)}
|
aria-invalid={!!(errors.name && touched.name)}
|
||||||
/>
|
/>
|
||||||
{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}
|
{errors.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -173,55 +175,28 @@ function LeadForm() {
|
|||||||
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
||||||
: 'border-gray-200'
|
: 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
placeholder="juan@empresa.com"
|
placeholder="juan@email.com"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
required
|
||||||
aria-required="true"
|
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)}
|
aria-invalid={!!(errors.email && touched.email)}
|
||||||
/>
|
/>
|
||||||
{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}
|
{errors.email}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
{/* Phone */}
|
||||||
<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">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="lead-phone" className="text-sm font-semibold text-dark">
|
<label htmlFor="lead-phone" className="text-sm font-semibold text-dark">
|
||||||
Teléfono <span className="font-normal text-gray-400">(opcional)</span>
|
Teléfono <span className="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="lead-phone"
|
id="lead-phone"
|
||||||
@@ -231,27 +206,80 @@ function LeadForm() {
|
|||||||
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
|
||||||
: 'border-gray-200'
|
: 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
placeholder="+52 55 1234 5678"
|
placeholder="+34 612 345 678"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
|
inputMode="tel"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby={errors.phone && touched.phone ? 'lead-phone-error' : undefined}
|
||||||
aria-invalid={!!(errors.phone && touched.phone)}
|
aria-invalid={!!(errors.phone && touched.phone)}
|
||||||
/>
|
/>
|
||||||
{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">
|
<span id="lead-phone-error" className="text-xs text-error font-medium" role="alert">
|
||||||
{errors.phone}
|
{errors.phone}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="submit"
|
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"
|
className="btn btn-primary w-full justify-center mt-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading' || !consentsGranted}
|
||||||
aria-busy={status === 'loading'}
|
aria-busy={status === 'loading'}
|
||||||
|
aria-disabled={status === 'loading' || !consentsGranted}
|
||||||
>
|
>
|
||||||
{status === 'loading' ? (
|
{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">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M2 8h12M10 4l4 4-4 4"
|
d="M2 8h12M10 4l4 4-4 4"
|
||||||
@@ -276,14 +304,6 @@ function LeadForm() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -310,14 +330,14 @@ export default function Hero() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-white overflow-hidden" id="hero" ref={heroRef} aria-label="Sección principal">
|
<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 */}
|
{/* 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 */}
|
{/* Columna izquierda — textos */}
|
||||||
<div className="flex flex-col gap-6">
|
<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,
|
Tu reforma,
|
||||||
<br />
|
<br />
|
||||||
<em className="italic font-black">presupuestada</em>
|
<em className="italic font-black">presupuestada</em>
|
||||||
@@ -325,44 +345,45 @@ export default function Hero() {
|
|||||||
en 5 minutos.
|
en 5 minutos.
|
||||||
</h1>
|
</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.
|
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>
|
</p>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* CTAs */}
|
||||||
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-200 flex flex-wrap gap-3">
|
<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
|
<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' })}
|
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">
|
<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" />
|
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-lg"
|
className="btn btn-secondary btn-lg w-full sm:w-auto"
|
||||||
onClick={() => document.querySelector('#features')?.scrollIntoView({ behavior: 'smooth' })}
|
onClick={() => document.querySelector('#ver-reforma')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
>
|
>
|
||||||
Ver características
|
Ver una reforma
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Columna derecha — formulario */}
|
{/* 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="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-6">
|
<div className="mb-5 md:mb-6">
|
||||||
<h2 className="text-xl font-black tracking-tight text-black">Recibe tu presupuesto gratis</h2>
|
<h2 className="text-xl font-black tracking-tight text-black">Pide tu presupuesto</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">Sin compromiso · En menos de 5 minutos</p>
|
<p className="text-sm text-gray-400 mt-1">En menos de 2 minutos te llamamos · Render por WhatsApp</p>
|
||||||
</div>
|
</div>
|
||||||
<LeadForm />
|
<LeadForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="border-gray-300 mt-12 pb-8" />
|
<hr className="border-gray-300 mt-12 md:mt-16 mb-8" />
|
||||||
|
|
||||||
{/* Servicios */}
|
{/* 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: (
|
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">
|
<div className="w-12 h-12 rounded-full bg-black flex items-center justify-center text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</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>
|
<p className="text-gray-400 leading-relaxed text-sm max-w-[280px]">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
const plans = [
|
|
||||||
{
|
|
||||||
id: 'esencial',
|
|
||||||
name: 'Presupuesto',
|
|
||||||
price: { monthly: 0, annual: 0 },
|
|
||||||
description: 'Para quien quiere saber cuánto cuesta antes de comprometerse.',
|
|
||||||
cta: 'Solicitar presupuesto',
|
|
||||||
highlight: false,
|
|
||||||
features: [
|
|
||||||
'Llamada en menos de 2 minutos',
|
|
||||||
'Presupuesto desglosado por partidas',
|
|
||||||
'Render visual de tu espacio por WhatsApp',
|
|
||||||
'Sin compromiso de contratación',
|
|
||||||
'Asesor desde tu provincia',
|
|
||||||
'Válido para cocinas y baños',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'reforma',
|
|
||||||
name: 'Reforma',
|
|
||||||
price: { monthly: 199, annual: 159 },
|
|
||||||
description: 'Para quien ya tiene claro que quiere reformar y necesita un equipo de confianza.',
|
|
||||||
cta: 'Empezar mi reforma',
|
|
||||||
highlight: true,
|
|
||||||
badge: 'Más contratado',
|
|
||||||
features: [
|
|
||||||
'Todo lo del plan Presupuesto',
|
|
||||||
'Proyecto de reforma completo',
|
|
||||||
'Gestión de materiales y proveedores',
|
|
||||||
'Coordinador de obra dedicado',
|
|
||||||
'Seguimiento fotográfico semanal',
|
|
||||||
'Garantía de 2 años en mano de obra',
|
|
||||||
'Plazos de entrega garantizados',
|
|
||||||
'Atención post-obra incluida',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'integral',
|
|
||||||
name: 'Integral',
|
|
||||||
price: { monthly: 499, annual: 399 },
|
|
||||||
description: 'Para reformas completas de viviendas o comunidades con gestión total.',
|
|
||||||
cta: 'Hablar con un asesor',
|
|
||||||
highlight: false,
|
|
||||||
features: [
|
|
||||||
'Todo lo del plan Reforma',
|
|
||||||
'Reforma integral de vivienda completa',
|
|
||||||
'Diseño de interiores incluido',
|
|
||||||
'Renders 3D de todos los espacios',
|
|
||||||
'Gestión de licencias y permisos',
|
|
||||||
'Financiación flexible disponible',
|
|
||||||
'Acceso a catálogo premium de materiales',
|
|
||||||
'Garantía extendida de 5 años',
|
|
||||||
'Servicio de mudanza coordinado',
|
|
||||||
'Revisión técnica anual',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Pricing() {
|
|
||||||
const [annual, setAnnual] = useState(false);
|
|
||||||
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 handleContactScroll = () => {
|
|
||||||
document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="section" id="pricing" ref={sectionRef} aria-labelledby="pricing-heading">
|
|
||||||
<div className="container">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col items-center text-center gap-4 mb-16">
|
|
||||||
<span className="badge badge-dark">Servicios</span>
|
|
||||||
<h2
|
|
||||||
id="pricing-heading"
|
|
||||||
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black"
|
|
||||||
>
|
|
||||||
Reformas con precio claro
|
|
||||||
<br />
|
|
||||||
desde el primer minuto.
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-gray-600">
|
|
||||||
Sin letras pequeñas, sin presupuestos que se disparan. Sabes lo que pagas antes de que empiece la obra.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Toggle */}
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<span
|
|
||||||
className={`text-sm transition-colors duration-150 ease-out ${
|
|
||||||
!annual ? 'font-semibold text-black' : 'font-medium text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Mensual
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className={`w-12 h-[26px] rounded-full border-none cursor-pointer relative transition-colors duration-250 ease-out ${
|
|
||||||
annual ? 'bg-black' : 'bg-gray-200'
|
|
||||||
}`}
|
|
||||||
onClick={() => setAnnual(!annual)}
|
|
||||||
aria-pressed={annual}
|
|
||||||
aria-label="Cambiar a facturación anual"
|
|
||||||
id="pricing-toggle-btn"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-[3px] left-[3px] w-5 h-5 bg-white rounded-full transition-transform duration-250 ease-out shadow-[0_1px_4px_rgba(0,0,0,0.2)] ${
|
|
||||||
annual ? 'translate-x-[22px]' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`text-sm flex items-center transition-colors duration-150 ease-out ${
|
|
||||||
annual ? 'font-semibold text-black' : 'font-medium text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Anual
|
|
||||||
<span className="inline-block ml-2 px-2 py-[2px] bg-green-100 text-green-700 text-xs font-bold rounded-full">
|
|
||||||
Ahorra 20%
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plans */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start max-w-[480px] lg:max-w-none mx-auto">
|
|
||||||
{plans.map((plan, i) => (
|
|
||||||
<article
|
|
||||||
key={plan.id}
|
|
||||||
className={`reveal opacity-0 translate-y-6 transition-all duration-700 ease-out border rounded-2xl p-8 flex flex-col gap-6 relative group ${
|
|
||||||
plan.highlight
|
|
||||||
? 'bg-black border-black text-white hover:shadow-[0_24px_64px_rgba(0,0,0,0.2)]'
|
|
||||||
: 'bg-white border-gray-200 hover:shadow-lg hover:-translate-y-0.5'
|
|
||||||
}`}
|
|
||||||
style={{ transitionDelay: `${i * 100}ms` }}
|
|
||||||
aria-label={`Plan ${plan.name}`}
|
|
||||||
>
|
|
||||||
{plan.badge && (
|
|
||||||
<div
|
|
||||||
className="absolute -top-[14px] left-1/2 -translate-x-1/2 bg-accent text-white text-xs font-bold px-4 py-1 rounded-full whitespace-nowrap tracking-wider"
|
|
||||||
aria-label="Plan más popular"
|
|
||||||
>
|
|
||||||
{plan.badge}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h3
|
|
||||||
className={`text-2xl font-extrabold tracking-tight ${
|
|
||||||
plan.highlight ? 'text-white' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plan.name}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className={`text-sm leading-relaxed ${
|
|
||||||
plan.highlight ? 'text-white/60' : 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plan.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end gap-[2px]">
|
|
||||||
{plan.price.monthly === 0 ? (
|
|
||||||
<span className="text-[clamp(2.25rem,6vw,3.5rem)] font-black tracking-[-0.05em] leading-none">
|
|
||||||
Gratis
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="text-xl font-bold pb-1">$</span>
|
|
||||||
<span className="text-[clamp(2.25rem,6vw,3.5rem)] font-black tracking-[-0.05em] leading-none">
|
|
||||||
{annual ? plan.price.annual : plan.price.monthly}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-sm pb-1 ml-1 ${
|
|
||||||
plan.highlight ? 'text-white/50' : 'text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
/mes
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{annual && plan.price.monthly > 0 && (
|
|
||||||
<p
|
|
||||||
className={`text-xs -mt-6 ${
|
|
||||||
plan.highlight ? 'text-white/40' : 'text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Facturado anualmente — ${plan.price.annual * 12}/año
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`btn btn-lg ${
|
|
||||||
plan.highlight ? 'btn-accent' : 'btn-secondary'
|
|
||||||
}`}
|
|
||||||
id={`pricing-${plan.id}-btn`}
|
|
||||||
onClick={handleContactScroll}
|
|
||||||
aria-label={`${plan.cta} — Plan ${plan.name}`}
|
|
||||||
>
|
|
||||||
{plan.cta}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul className="list-none flex flex-col gap-3" role="list">
|
|
||||||
{plan.features.map((f) => (
|
|
||||||
<li
|
|
||||||
key={f}
|
|
||||||
className={`flex items-center gap-3 text-sm ${
|
|
||||||
plan.highlight ? 'text-white/80' : 'text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`shrink-0 ${plan.highlight ? 'text-white' : 'text-black'}`}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M3 8l3.5 3.5L13 4.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{f}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enterprise note */}
|
|
||||||
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out text-center mt-12 text-base text-gray-600">
|
|
||||||
<p>
|
|
||||||
¿Tienes un proyecto especial o una comunidad de vecinos?
|
|
||||||
<button
|
|
||||||
className="bg-transparent border-none cursor-pointer font-sans text-base font-semibold text-black underline underline-offset-4 transition-opacity duration-150 ease-out hover:opacity-60"
|
|
||||||
onClick={handleContactScroll}
|
|
||||||
id="pricing-enterprise-contact-btn"
|
|
||||||
>
|
|
||||||
Cuéntanos tu caso →
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,18 +5,18 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
const slides = [
|
const slides = [
|
||||||
{
|
{
|
||||||
label: 'Cocina',
|
label: 'Cocina',
|
||||||
before: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=900',
|
before: '/antes.webp',
|
||||||
after: 'https://images.unsplash.com/photo-1556909172-54557c7e4fb7?w=900',
|
after: '/despues.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Baño',
|
label: 'Baño',
|
||||||
before: 'https://images.unsplash.com/photo-1552321554-5fefe8c9ef14?w=900',
|
before: '/antes-bano.webp',
|
||||||
after: 'https://images.unsplash.com/photo-1584622650111-993a426fbf0a?w=900',
|
after: '/despues-bano.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Salón',
|
label: 'Comedor',
|
||||||
before: 'https://images.unsplash.com/photo-1484101403633-562f891dc89a?w=900',
|
before: '/antes-comedor.webp',
|
||||||
after: 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea6?w=900',
|
after: '/despues-comedor.webp',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -63,9 +63,9 @@ export default function ReformaSlider() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-white py-16 md:py-24" ref={sectionRef}>
|
<section id="ver-reforma" className="bg-white py-16 md:py-24" ref={sectionRef}>
|
||||||
<div className="container text-center">
|
<div className="container text-center">
|
||||||
<h2 className="text-[clamp(2.5rem,5vw,4rem)] font-black tracking-[-0.04em] leading-[1.05] text-black mb-6 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out">
|
<h2 className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black mb-10 md:mb-12 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out">
|
||||||
Ve cómo quedará tu reforma
|
Ve cómo quedará tu reforma
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col gap-4 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100">
|
<div className="flex flex-col gap-4 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100">
|
||||||
@@ -119,6 +119,7 @@ export default function ReformaSlider() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{slides.length > 1 && (
|
||||||
<div className="flex gap-3 justify-center mt-2">
|
<div className="flex gap-3 justify-center mt-2">
|
||||||
{slides.map((slide, i) => (
|
{slides.map((slide, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -135,6 +136,7 @@ export default function ReformaSlider() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
122
mvp/b2c/src/components/Testimonials/Testimonials.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
type Testimonial = {
|
||||||
|
quote: string;
|
||||||
|
name: string;
|
||||||
|
initials: string;
|
||||||
|
context: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const testimonials: Testimonial[] = [
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
'Me llamaron en 90 segundos. El presupuesto orientativo cuadró con el final a 200 € de diferencia. Me ahorré tres visitas en blanco.',
|
||||||
|
name: 'Laura Martínez',
|
||||||
|
initials: 'LM',
|
||||||
|
context: 'Cocina en Madrid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
'Subí tres fotos del baño un domingo por la noche. El lunes a las nueve ya tenía un presupuesto desglosado en el WhatsApp.',
|
||||||
|
name: 'Sergio Bonet',
|
||||||
|
initials: 'SB',
|
||||||
|
context: 'Baño en Valencia',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
'El render me sirvió para decidir el suelo sin ir a la tienda. Cuando llegó el reformista, ya teníamos media reforma clara.',
|
||||||
|
name: 'Marta Vidal',
|
||||||
|
initials: 'MV',
|
||||||
|
context: 'Comedor en Barcelona',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Testimonials() {
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="section"
|
||||||
|
id="testimonios"
|
||||||
|
ref={sectionRef}
|
||||||
|
aria-labelledby="testimonios-heading"
|
||||||
|
>
|
||||||
|
<div className="container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col items-center text-center gap-4 mb-16">
|
||||||
|
<span className="badge badge-dark">Testimonios</span>
|
||||||
|
<h2
|
||||||
|
id="testimonios-heading"
|
||||||
|
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black max-w-3xl"
|
||||||
|
>
|
||||||
|
Lo que dicen
|
||||||
|
<br />
|
||||||
|
quienes ya reformaron.
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600 max-w-xl">
|
||||||
|
Mismo método, distintos espacios. Sin filtros ni guion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||||
|
{testimonials.map((t, i) => (
|
||||||
|
<article
|
||||||
|
key={t.name}
|
||||||
|
className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col gap-5 bg-white border border-gray-200 rounded-2xl p-8 shadow-sm hover:shadow-md"
|
||||||
|
style={{ transitionDelay: `${i * 80}ms` }}
|
||||||
|
>
|
||||||
|
{/* Quote mark */}
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 32 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-black/15"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0 24V13.6C0 6.08 4.16 0.96 12.48 0L13.44 4.16C8.96 5.44 6.72 8.32 6.72 11.52H12.48V24H0ZM18.56 24V13.6C18.56 6.08 22.72 0.96 31.04 0L32 4.16C27.52 5.44 25.28 8.32 25.28 11.52H31.04V24H18.56Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<blockquote className="text-lg leading-relaxed text-gray-800 italic">
|
||||||
|
{t.quote}
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<footer className="flex items-center gap-3 mt-auto pt-4 border-t border-gray-100">
|
||||||
|
<div className="w-10 h-10 bg-black text-white rounded-full flex items-center justify-center text-xs font-bold tracking-wider shrink-0">
|
||||||
|
{t.initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-black">{t.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{t.context}</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||