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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
mvp/b2c/public/antes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
mvp/b2c/public/despues.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

4
mvp/b2c/public/icon.svg Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -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',
}, },
}; };

View File

@@ -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 />
</> </>

View File

@@ -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 &lt; 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>

View File

@@ -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">

View File

@@ -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>
))} ))}

View File

@@ -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? &nbsp;
<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>
);
}

View File

@@ -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>

View 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>
);
}