Reordenando ficheros y subida de documentacion

This commit is contained in:
Carlos Narro
2026-05-27 10:27:27 +02:00
parent 6388fcaba1
commit a9ad2d7e31
52 changed files with 4707 additions and 79 deletions

5
mvp/b2c/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
mvp/b2c/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

100
mvp/b2c/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Reformix — Landing Page
> Landing page pública de **Reformix**, la plataforma que conecta a propietarios con reformistas de confianza. Presupuesto en 5 minutos, render por WhatsApp. Construida con **Next.js + TypeScript + Tailwind CSS v4**.
## 🚀 Inicio rápido
```bash
# Instalar dependencias
npm install
# Iniciar servidor de desarrollo
npm run dev
```
Abre [http://localhost:3000](http://localhost:3000) en tu navegador para ver el resultado.
---
## 🛠 Stack tecnológico
| Capa | Tecnología |
|---|---|
| Framework | Next.js (App Router) |
| Lenguaje | TypeScript 5 |
| Estilos | Tailwind CSS v4 (`@import "tailwindcss"` + `@theme`) |
| Componentes | React 19 (Server + Client Components) |
| Fuente | Inter (Google Fonts, variable) |
---
## 📁 Arquitectura del proyecto
```text
landing-page/
├── src/
│ ├── app/
│ │ ├── globals.css # Design system: @theme tokens + @layer utilities
│ │ ├── layout.tsx # Root layout con fuente Inter y meta tags
│ │ └── page.tsx # Composición de página — solo imports de componentes
│ │
│ └── components/
│ ├── Navbar/ # Barra de navegación (actualmente comentada)
│ ├── Hero/ # Sección principal: headline + LeadForm interno
│ ├── ReformaSlider/ # Comparador antes/después interactivo (drag)
│ ├── Features/ # Sección "Cómo funciona" — 3 pasos numerados
│ ├── Pricing/ # Tabla de servicios: Presupuesto · Reforma · Integral
│ ├── ContactForm/ # Formulario de contacto con validación y testimonial
│ └── Footer/ # Links, redes sociales y copyright
```
### Convención de componentes
- Cada componente vive en su propia carpeta `ComponentName/ComponentName.tsx`.
- Los sub-componentes de uso exclusivo (ej. `LeadForm`) se definen en el mismo archivo del padre, **no** como archivos separados.
- `page.tsx` solo importa componentes de primer nivel — sin JSX estructural inline.
---
## 🎨 Design System
Definido en `globals.css` mediante el bloque `@theme` de Tailwind v4:
- **Colores:** Escala negro/blanco de alto contraste. Sin acentos de color innecesarios.
- **Tipografía:** Inter variable — pesos 400 a 900, tracking ajustado para headlines impactantes.
- **Animaciones:** Entrada por scroll con `IntersectionObserver` + clase `reveal`. Micro-interacciones en hovers de cards y botones.
- **Responsive:** Mobile-first. Grid de 1 columna en móvil, 23 en desktop.
---
## 🧩 Secciones de la landing
| Sección | Descripción |
|---|---|
| **Hero** | Headline + formulario de captación de lead (nombre, email, empresa, teléfono) |
| **ReformaSlider** | Comparador antes/después con drag handle — 3 espacios: Cocina, Baño, Salón |
| **Features** | Explicación del proceso en 3 pasos: llama → foto → presupuesto |
| **Pricing** | 3 niveles de servicio con toggle mensual/anual y features detalladas |
| **ContactForm** | Formulario completo con datos de contacto y testimonial de cliente real |
| **Footer** | Links de navegación, redes sociales, estado operativo y copyright |
---
## 📝 Scripts disponibles
```bash
npm run dev # Desarrollo con Turbopack (hot reload)
npm run build # Build de producción optimizado
npm run start # Servidor de producción con la build generada
npm run lint # ESLint — análisis estático del código
```
---
## 🔗 Repositorio
GitHub: [McGregory99/reformix-hackaton](https://github.com/McGregory99/reformix-hackaton)
---
*Desarrollado para el hackathon interno de Reformix.*

18
mvp/b2c/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
mvp/b2c/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6597
mvp/b2c/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
mvp/b2c/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "landing-page",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tailwindcss/postcss": "^4.3.0",
"next": "16.2.6",
"postcss": "^8.5.15",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

1
mvp/b2c/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
mvp/b2c/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
mvp/b2c/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

BIN
mvp/b2c/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

113
mvp/b2c/src/app/globals.css Normal file
View File

@@ -0,0 +1,113 @@
@import "tailwindcss";
@theme {
/* Colors */
--color-black: #0a0a0a;
--color-dark: #111111;
--color-gray-900: #1a1a1a;
--color-gray-800: #2d2d2d;
--color-gray-600: #555555;
--color-gray-400: #888888;
--color-gray-300: #d1d1d1;
--color-gray-200: #e5e5e5;
--color-gray-100: #f5f5f5;
--color-gray-50: #fafafa;
--color-white: #ffffff;
/* Accent */
--color-accent: #0066ff;
--color-accent-dark: #0052cc;
--color-accent-light: #e8f0fe;
/* Status */
--color-success: #00c853;
--color-error: #ff3b3b;
/* Fonts */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 400ms ease;
}
@layer base {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
font-size: 1rem;
color: var(--color-dark);
background-color: var(--color-white);
line-height: 1.6;
overflow-x: hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-gray-100);
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-400);
border-radius: 9999px;
}
}
@layer components {
/* Layout */
.container {
@apply w-full max-w-7xl mx-auto px-6 md:px-4;
}
.section {
@apply py-16 md:py-24;
}
/* Buttons */
.btn {
@apply inline-flex items-center justify-center gap-2 px-6 py-3 text-base font-semibold rounded-lg transition-all duration-250 ease-out whitespace-nowrap cursor-pointer tracking-tight;
}
.btn-primary {
@apply bg-black text-white border-2 border-black hover:bg-gray-900 hover:border-gray-900 hover:-translate-y-[1px] hover:shadow-[0_8px_32px_rgba(0,0,0,0.10)];
}
.btn-secondary {
@apply bg-transparent text-dark border-2 border-gray-200 hover:border-dark hover:-translate-y-[1px];
}
.btn-accent {
@apply bg-accent text-white border-2 border-accent hover:bg-accent-dark hover:border-accent-dark hover:-translate-y-[1px] hover:shadow-[0_8px_24px_rgba(0,102,255,0.3)];
}
.btn-lg {
@apply px-8 py-4 text-lg rounded-xl;
}
/* Badges */
.badge {
@apply inline-flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-widest rounded-full;
}
.badge-dark {
@apply bg-black text-white;
}
.badge-accent {
@apply bg-accent-light text-accent;
}
}

View File

@@ -0,0 +1,26 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'FlowSync — El SaaS que impulsa tu equipo',
description:
'Automatiza flujos de trabajo, conecta equipos y escala tu negocio con FlowSync. La plataforma SaaS todo-en-uno para equipos modernos.',
keywords: ['SaaS', 'productividad', 'automatización', 'equipos', 'gestión de proyectos'],
openGraph: {
title: 'FlowSync — El SaaS que impulsa tu equipo',
description: 'Automatiza flujos de trabajo y escala tu negocio con FlowSync.',
type: 'website',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es">
<body>{children}</body>
</html>
);
}

22
mvp/b2c/src/app/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Navbar from '@/components/Navbar/Navbar';
import Hero from '@/components/Hero/Hero';
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
import Features from '@/components/Features/Features';
import Pricing from '@/components/Pricing/Pricing';
import ContactForm from '@/components/ContactForm/ContactForm';
import Footer from '@/components/Footer/Footer';
export default function Home() {
return (
<>
{/* <Navbar /> */}
<main id="main-content">
<Hero />
<ReformaSlider />
<Features />
<Pricing />
</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,459 @@
'use client';
import { useState, useRef, useEffect, FormEvent } from 'react';
type FormData = {
name: string;
email: string;
company: string;
phone: string;
message: string;
};
type FormErrors = Partial<Record<keyof FormData, string>>;
type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
const initialData: FormData = {
name: '',
email: '',
company: '',
phone: '',
message: '',
};
function validateForm(data: FormData): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) errors.name = 'El nombre es requerido';
if (!data.email.trim()) {
errors.email = 'El email es requerido';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = 'Ingresa un email válido';
}
if (!data.company.trim()) errors.company = 'La empresa es requerida';
if (data.phone && !/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
errors.phone = 'Ingresa un teléfono válido';
}
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;
}
export default function ContactForm() {
const [formData, setFormData] = useState<FormData>(initialData);
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
const [status, setStatus] = useState<SubmitStatus>('idle');
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('opacity-100', 'translate-y-0');
entry.target.classList.remove('opacity-0', 'translate-y-6');
}
});
},
{ threshold: 0.1 }
);
const elements = sectionRef.current?.querySelectorAll('.reveal');
elements?.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (touched[name as keyof FormData]) {
const newErrors = validateForm({ ...formData, [name]: value });
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
}
};
const handleBlur = (
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
const newErrors = validateForm(formData);
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const allTouched = Object.keys(formData).reduce(
(acc, k) => ({ ...acc, [k]: true }),
{} as Record<keyof FormData, boolean>
);
setTouched(allTouched);
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setStatus('loading');
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1800));
setStatus('success');
setFormData(initialData);
setTouched({});
setErrors({});
};
const handleReset = () => {
setStatus('idle');
setFormData(initialData);
setErrors({});
setTouched({});
};
return (
<section
className="section bg-white"
id="contact"
ref={sectionRef}
aria-labelledby="contact-heading"
>
<div className="container">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-16 items-start">
{/* Left info panel */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col gap-6 lg:sticky lg:top-[104px]">
<div className="mb-2">
<span className="badge badge-dark">Contacto</span>
</div>
<h2
id="contact-heading"
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.05] text-black"
>
Hablemos de
<br />
tu reforma
</h2>
<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.
</p>
{/* Contact details */}
<div className="flex flex-col gap-4 p-6 bg-gray-50 border border-gray-200 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-white border border-gray-200 rounded-lg flex items-center justify-center shrink-0 text-black">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
stroke="currentColor"
strokeWidth="2"
/>
<polyline points="22,6 12,13 2,6" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Email</div>
<div className="text-base font-semibold text-black">hola@reformix.es</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-white border border-gray-200 rounded-lg flex items-center justify-center shrink-0 text-black">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.62a19.79 19.79 0 01-3.07-8.63A2 2 0 012.18 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.91 7.91a16 16 0 006.18 6.18l1.28-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Teléfono</div>
<div className="text-base font-semibold text-black">+34 900 123 456</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-white border border-gray-200 rounded-lg flex items-center justify-center shrink-0 text-black">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Respuesta</div>
<div className="text-base font-semibold text-black">Menos de 24 horas</div>
</div>
</div>
</div>
{/* Testimonial */}
<blockquote className="bg-black text-white rounded-xl p-6 flex flex-col gap-4">
<p className="text-base leading-relaxed text-white/85 italic">
&ldquo;Reformix me dio un presupuesto en 5 minutos. A la semana ya tenía a los operarios en casa. Mejor reforma que la que pedí.&rdquo;
</p>
<footer className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/15 rounded-full flex items-center justify-center text-xs font-bold tracking-wider shrink-0">
LM
</div>
<div>
<div className="text-sm font-bold">Laura Martínez</div>
<div className="text-xs text-white/50">Reforma de cocina en Madrid</div>
</div>
</footer>
</blockquote>
</div>
{/* Form panel */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-150 bg-white border border-gray-200 rounded-2xl p-10 max-sm:p-6 shadow-lg">
{status === 'success' ? (
<div
className="flex flex-col items-center justify-center text-center gap-4 py-16 px-8 animate-scaleIn"
role="alert"
aria-live="polite"
>
<div className="w-18 h-18 bg-black text-white rounded-full flex items-center justify-center mb-2">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M6 16l7 7L26 9"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h3 className="text-2xl font-extrabold tracking-tight text-black">
¡Mensaje enviado!
</h3>
<p className="text-base text-gray-600 max-w-[320px] leading-relaxed mb-4">
Gracias por contactarnos. Nuestro equipo te responderá en menos de 24 horas.
</p>
<button
className="btn btn-secondary"
onClick={handleReset}
id="contact-send-another-btn"
>
Enviar otro mensaje
</button>
</div>
) : (
<form
className="flex flex-col gap-5"
onSubmit={handleSubmit}
noValidate
aria-label="Formulario de contacto"
id="contact-form"
>
<div className="mb-2">
<h3 className="text-2xl font-extrabold tracking-tight text-black">
Envíanos un mensaje
</h3>
<p className="text-sm text-gray-400 mt-1">
Todos los campos marcados con * son requeridos
</p>
</div>
{/* Row: Name + Email */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="contact-name" className="text-sm font-semibold text-dark">
Nombre completo <span className="text-error">*</span>
</label>
<input
id="contact-name"
name="name"
type="text"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.name && touched.name
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="Juan García"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="name"
aria-required="true"
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
aria-invalid={!!(errors.name && touched.name)}
/>
{errors.name && touched.name && (
<span id="name-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.name}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="contact-email" className="text-sm font-semibold text-dark">
Email <span className="text-error">*</span>
</label>
<input
id="contact-email"
name="email"
type="email"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.email && touched.email
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="juan@empresa.com"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="email"
aria-required="true"
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
aria-invalid={!!(errors.email && touched.email)}
/>
{errors.email && touched.email && (
<span id="email-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.email}
</span>
)}
</div>
</div>
{/* 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>
<div className="flex flex-col gap-2">
<label htmlFor="contact-phone" className="text-sm font-semibold text-dark">
Teléfono
<span className="font-normal text-gray-400"> (opcional)</span>
</label>
<input
id="contact-phone"
name="phone"
type="tel"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.phone && touched.phone
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="+52 55 1234 5678"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="tel"
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
aria-invalid={!!(errors.phone && touched.phone)}
/>
{errors.phone && touched.phone && (
<span id="phone-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.phone}
</span>
)}
</div>
</div>
{/* Message */}
<div className="flex flex-col gap-2">
<label htmlFor="contact-message" className="text-sm font-semibold text-dark">
Mensaje <span className="text-error">*</span>
</label>
<textarea
id="contact-message"
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
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="Cuéntanos sobre tu reforma: qué espacio quieres reformar, cuál es tu presupuesto aproximado y en qué ciudad vives..."
rows={5}
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}
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">
{errors.message && touched.message ? (
<span id="message-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.message}
</span>
) : (
<span />
)}
<span className="text-xs text-gray-400 ml-auto">
{formData.message.length} caracteres
</span>
</div>
</div>
{/* Submit */}
<button
type="submit"
className="btn btn-primary btn-lg w-full justify-center mt-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
disabled={status === 'loading'}
id="contact-submit-btn"
aria-busy={status === 'loading'}
>
{status === 'loading' ? (
<>
<span
className="w-[18px] h-[18px] border-2 border-white/30 border-t-white rounded-full animate-[spin_0.7s_linear_infinite] shrink-0"
aria-hidden="true"
/>
Enviando...
</>
) : (
<>
Enviar mensaje
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2 8h12M10 4l4 4-4 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</>
)}
</button>
<p className="text-xs text-gray-400 text-center leading-relaxed">
Al enviar, aceptas nuestra{' '}
<a href="#" className="text-gray-600 underline underline-offset-2 transition-colors duration-150 hover:text-black">
política de privacidad
</a>
. Nunca compartiremos tu información.
</p>
</form>
)}
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useRef } from 'react';
const features = [
{
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
title: 'Deja tu número',
description: 'Rellena tu nombre, teléfono y email. En menos de 2 minutos te llamamos.',
tag: '01',
},
{
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
title: 'Sube una foto',
description: 'Fotografía tu cocina o baño. Nuestra IA analiza el espacio.',
tag: '02',
},
{
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<polyline
points="22 12 18 12 15 21 9 3 6 12 2 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
title: 'Recibe tu presupuesto',
description: 'Al colgar te llega por WhatsApp el render + presupuesto desglosado.',
tag: '03',
},
];
export default function Features() {
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="bg-gray-50 border-y border-gray-200 py-24"
id="features"
ref={sectionRef}
aria-labelledby="features-heading"
>
<div className="container">
{/* Header */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out text-center max-w-[600px] mx-auto mb-16 flex flex-col items-center gap-4">
<span className="badge badge-accent">Cómo funciona</span>
<h2
id="features-heading"
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black"
>
Tu presupuesto en 5 minutos,
<br />
<span className="text-gray-600">sin salir de casa</span>
</h2>
<p className="text-lg text-gray-600 leading-relaxed">
Sin visitas, sin esperas, sin sorpresas. Solo deja tu número, sube una foto
y te llamamos desde tu provincia con presupuesto en mano.
</p>
</div>
{/* Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{features.map((feature, i) => (
<article
key={feature.title}
className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out bg-white border border-gray-200 rounded-xl p-8 max-sm:p-6 flex flex-col gap-4 cursor-default hover:border-black hover:shadow-lg hover:-translate-y-0.5 group"
style={{ transitionDelay: `${i * 80}ms` }}
aria-label={feature.title}
>
<div className="w-12 h-12 bg-black text-white rounded-lg flex items-center justify-center shrink-0 transition-transform duration-250 ease-out group-hover:scale-105 group-hover:-rotate-2">
{feature.icon}
</div>
<div className="flex flex-col gap-2">
<div className="text-[6rem] font-black leading-none text-gray-300 select-none -mt-2 -mb-2">
{feature.tag}
</div>
<h3 className="text-xl font-extrabold tracking-tight text-black leading-tight">
{feature.title}
</h3>
<p className="text-sm text-gray-600 leading-relaxed">
{feature.description}
</p>
</div>
</article>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
const footerLinks = {
Servicios: ['Reformas integrales', 'Reformas de cocinas', 'Reformas de baños', 'Diseño de interiores', 'Comunidades de vecinos'],
Empresa: ['Sobre nosotros', 'Blog de reformas', 'Trabaja con nosotros', 'Prensa', 'Zona de trabajo'],
Ayuda: ['Cómo funciona', 'Preguntas frecuentes', 'Galeria de proyectos', 'Financiación', 'Calculadora de obra'],
Legal: ['Privacidad', 'Términos', 'Cookies', 'RGPD', 'Aviso legal'],
};
const socials = [
{
name: 'Twitter',
href: '#',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
),
},
{
name: 'LinkedIn',
href: '#',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
),
},
{
name: 'GitHub',
href: '#',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
</svg>
),
},
];
export default function Footer() {
return (
<footer className="bg-black text-white" role="contentinfo">
{/* Main footer */}
<div className="py-16 pb-8">
<div className="container">
<div className="grid grid-cols-[280px_1fr] max-lg:grid-cols-1 gap-16 lg:gap-16 max-lg:gap-12 mb-12">
{/* Brand */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="w-9 h-9 bg-white text-black rounded-md flex items-center justify-center text-xs font-extrabold tracking-[-0.02em]">
Rx
</span>
<span className="text-lg font-extrabold tracking-[-0.04em] text-white">
Reformix
</span>
</div>
<p className="text-sm text-white/45 leading-relaxed">
Reformas de cocinas y baños con presupuesto en 5 minutos y render por WhatsApp. Tu reforma, gestionada de principio a fin.
</p>
<div className="flex gap-2">
{socials.map((s) => (
<a
key={s.name}
href={s.href}
className="w-9 h-9 bg-white/10 text-white/60 rounded-md flex items-center justify-center transition-all duration-150 hover:bg-white/15 hover:text-white"
aria-label={s.name}
id={`footer-social-${s.name.toLowerCase()}`}
>
{s.icon}
</a>
))}
</div>
</div>
{/* Links */}
<nav className="grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-2 max-sm:gap-6 gap-8" aria-label="Footer navigation">
{Object.entries(footerLinks).map(([category, links]) => (
<div key={category} className="flex flex-col gap-4">
<h3 className="text-xs font-bold uppercase tracking-widest text-white/35">
{category}
</h3>
<ul className="list-none flex flex-col gap-3" role="list">
{links.map((link) => (
<li key={link}>
<a
href="#"
className="text-sm text-white/55 no-underline transition-colors duration-150 hover:text-white"
>
{link}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</div>
{/* Bottom bar */}
<div className="flex items-center justify-between pt-8 border-t border-white/10 flex-wrap gap-4">
<p className="text-sm text-white/35">
© {new Date().getFullYear()} Reformix, S.L. Todos los derechos reservados.
</p>
<div className="flex items-center gap-2 text-xs font-medium text-white/45">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Reformas en curso ahora mismo
</div>
</div>
</div>
</div>
</footer>
);
}

View File

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

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useEffect } from 'react';
const navLinks = [
{ label: 'Características', href: '#features' },
{ label: 'Precios', href: '#pricing' },
{ label: 'Contacto', href: '#contact' },
];
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 24);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleNavClick = (href: string) => {
setMenuOpen(false);
const el = document.querySelector(href);
if (el) el.scrollIntoView({ behavior: 'smooth' });
};
return (
<header
className={`fixed top-0 left-0 right-0 z-[1000] bg-white/90 backdrop-blur-md border-b transition-all duration-250 ease-out ${
scrolled ? 'border-gray-200 shadow-sm' : 'border-transparent'
}`}
role="banner"
>
<nav
className="container flex items-center justify-between h-[72px] gap-8"
aria-label="Navegación principal"
>
{/* Logo */}
<a
href="#"
className="flex items-center gap-2 no-underline shrink-0"
aria-label="FlowSync - inicio"
>
<span className="w-9 h-9 bg-black text-white rounded-md flex items-center justify-center text-xs font-extrabold tracking-[-0.02em]">
FS
</span>
<span className="text-lg font-extrabold text-black tracking-[-0.04em]">
FlowSync
</span>
</a>
{/* Desktop links */}
<ul
className="hidden md:flex items-center justify-center gap-1 list-none flex-1"
role="list"
>
{navLinks.map((link) => (
<li key={link.href}>
<button
className="px-3 py-2 text-sm font-medium text-gray-600 rounded-md transition-colors duration-150 ease-out bg-transparent border-none cursor-pointer hover:text-black hover:bg-gray-100"
onClick={() => handleNavClick(link.href)}
aria-label={`Ir a sección ${link.label}`}
>
{link.label}
</button>
</li>
))}
</ul>
{/* Desktop CTA */}
<div className="hidden md:flex items-center gap-3 shrink-0">
<button className="btn btn-secondary" id="nav-login-btn">
Iniciar sesión
</button>
<button
className="btn btn-primary"
id="nav-cta-btn"
onClick={() => handleNavClick('#contact')}
>
Prueba gratis
</button>
</div>
{/* Mobile toggle */}
<button
className="md:hidden flex flex-col gap-[5px] p-2 rounded-md bg-transparent border-none cursor-pointer"
onClick={() => setMenuOpen(!menuOpen)}
aria-label={menuOpen ? 'Cerrar menú' : 'Abrir menú'}
aria-expanded={menuOpen}
id="nav-menu-toggle"
>
<span
className={`block w-[22px] h-[2px] bg-black rounded-sm transition-transform duration-150 ease-out origin-center ${
menuOpen ? 'translate-y-[7px] rotate-45' : ''
}`}
/>
<span
className={`block w-[22px] h-[2px] bg-black rounded-sm transition-all duration-150 ease-out ${
menuOpen ? 'opacity-0 scale-x-0' : ''
}`}
/>
<span
className={`block w-[22px] h-[2px] bg-black rounded-sm transition-transform duration-150 ease-out origin-center ${
menuOpen ? '-translate-y-[7px] -rotate-45' : ''
}`}
/>
</button>
</nav>
{/* Mobile menu */}
{menuOpen && (
<div
className="md:hidden bg-white border-t border-gray-200 px-6 py-4 animate-fadeInUp"
role="dialog"
aria-label="Menú móvil"
>
<ul className="list-none flex flex-col gap-1 mb-4" role="list">
{navLinks.map((link) => (
<li key={link.href}>
<button
className="block w-full text-left px-4 py-3 text-base font-medium text-dark rounded-md bg-transparent border-none cursor-pointer transition-colors duration-150 ease-out hover:bg-gray-100"
onClick={() => handleNavClick(link.href)}
>
{link.label}
</button>
</li>
))}
</ul>
<div className="flex flex-col gap-3">
<button className="btn btn-secondary w-full">Iniciar sesión</button>
<button
className="btn btn-primary w-full"
onClick={() => handleNavClick('#contact')}
>
Prueba gratis
</button>
</div>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,273 @@
'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

@@ -0,0 +1,142 @@
'use client';
import { useState, useRef, useEffect } from 'react';
const slides = [
{
label: 'Cocina',
before: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=900',
after: 'https://images.unsplash.com/photo-1556909172-54557c7e4fb7?w=900',
},
{
label: 'Baño',
before: 'https://images.unsplash.com/photo-1552321554-5fefe8c9ef14?w=900',
after: 'https://images.unsplash.com/photo-1584622650111-993a426fbf0a?w=900',
},
{
label: 'Salón',
before: 'https://images.unsplash.com/photo-1484101403633-562f891dc89a?w=900',
after: 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea6?w=900',
},
];
export default function ReformaSlider() {
const [activeSlide, setActiveSlide] = useState(0);
const [sliderX, setSliderX] = useState(50);
const [dragging, setDragging] = useState(false);
const [visible, setVisible] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
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 handleMove = (clientX: number) => {
if (!dragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 100;
setSliderX(Math.min(Math.max(x, 5), 95));
};
const switchSlide = (index: number) => {
if (index === activeSlide) return;
setVisible(false);
setTimeout(() => {
setActiveSlide(index);
setSliderX(50);
setVisible(true);
}, 300);
};
return (
<section className="bg-white py-16 md:py-24" ref={sectionRef}>
<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">
Ve cómo quedará tu reforma
</h2>
<div className="flex flex-col gap-4 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100">
<div
ref={containerRef}
className="relative w-full aspect-[16/9] rounded-xl overflow-hidden cursor-col-resize select-none"
onMouseDown={() => setDragging(true)}
onMouseUp={() => setDragging(false)}
onMouseLeave={() => setDragging(false)}
onMouseMove={(e) => handleMove(e.clientX)}
onTouchStart={() => setDragging(true)}
onTouchEnd={() => setDragging(false)}
onTouchMove={(e) => handleMove(e.touches[0].clientX)}
>
<div
className="absolute inset-0 transition-opacity duration-300"
style={{ opacity: visible ? 1 : 0 }}
>
<img
src={slides[activeSlide].after}
alt="Después de la reforma"
className="absolute inset-0 w-full h-full object-cover"
/>
<div
className="absolute inset-0 overflow-hidden"
style={{ width: `${sliderX}%` }}
>
<img
src={slides[activeSlide].before}
alt="Antes de la reforma"
className="absolute inset-0 w-full h-full object-cover"
style={{ width: `${(100 / sliderX) * 100}%`, maxWidth: 'none' }}
/>
</div>
<span className="absolute top-3 left-4 bg-black/70 text-white text-xs font-medium px-3 py-1 rounded-full">
Antes
</span>
<span className="absolute top-3 right-4 bg-white/90 text-black text-xs font-medium px-3 py-1 rounded-full">
Después
</span>
<div
className="absolute top-0 bottom-0 w-[2px] bg-white shadow-lg"
style={{ left: `${sliderX}%` }}
>
<div className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 bg-white rounded-full shadow-xl flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M8 4l-4 8 4 8M16 4l4 8-4 8" stroke="#1A1A1A" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
</div>
</div>
<div className="flex gap-3 justify-center mt-2">
{slides.map((slide, i) => (
<button
key={slide.label}
onClick={() => switchSlide(i)}
className={`relative rounded-lg overflow-hidden w-24 h-16 shrink-0 transition-all duration-200 ${i === activeSlide ? 'ring-2 ring-black ring-offset-2' : 'opacity-50 hover:opacity-80'
}`}
aria-label={`Ver reforma de ${slide.label}`}
>
<img src={slide.before} alt={slide.label} className="w-full h-full object-cover" />
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 bg-black/70 text-white text-[10px] px-2 py-0.5 rounded-full whitespace-nowrap">
{slide.label}
</span>
</button>
))}
</div>
</div>
</div>
</section>
);
}

34
mvp/b2c/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}