Compare commits

...

7 Commits

Author SHA1 Message Date
Carlos Narro
cd38fe6233 Reconcilia la rama de Gitea con la integrada (GitHub)
Une la punta de Gitea (8ef99b5, que solo añadía la línea del .gitignore de la
colección Postman, ya presente aquí) con la rama integrada que incluye el
trabajo de Goyo (motor de presupuesto: impermeabilización, extras, zonas) y los
emails transaccionales + logo. Estrategia 'ours': se conserva el árbol actual
(no se pierde nada) y ambos remotos quedan en fast-forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:48:22 +02:00
Carlos Narro
4d464d40ef Incrusta el logo del reformista en los emails (CID) + tema de marca
- prepararLogo: el logo (data URI base64, URL http o ruta /public con APP_URL)
  se adjunta como imagen inline con Content-ID y se referencia con cid:, que
  es la forma fiable de mostrarlo en Gmail/Apple Mail/Outlook (el data URI
  directo lo bloquea Gmail). Fallback al nombre de la empresa si no hay logo.
- El acento (barra, botón) ya usa el color de marca del tenant (resolveTheme).

Probado: envío real con el logo de Reformas Ejemplo embebido y tema pizarra.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:41:59 +02:00
Carlos Narro
2bc34d4017 Mejora los emails transaccionales (diseño premium + marca)
Reescribe los emails del funnel (entrega del presupuesto + enlace al
formulario) con plantilla HTML mobile-first de una columna, dark mode, botón
"bulletproof" en tabla, tipografías de sistema y versión en texto plano.

- mailer.ts: construirEmailHtml/construirEmailText compartidos; acento en el
  color de marca del reformista (resolveTheme) + logo si es URL absoluta; copy
  con jerarquía y CTA orientado a resultado.
- finalizar.ts: pasa la marca del tenant y un CTA de contacto (wa.me del
  reformista o mailto) al email de entrega.
- actions.ts: pasa la marca al email con el enlace al formulario.
- COPY-GUIDE §6.b: asuntos (+alternativas), preheader, headline y cuerpo
  mejorados.

Probado: render visual (light) de ambos emails y envío real por SMTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:35:10 +02:00
Goyo Cancio
2e3cd78216 Añade impermeabilización, extras fijos y zonas al motor de presupuesto
Acerca el cálculo a tarifas de mercado sin rehacer el modelo lineal €/m²:
- Impermeabilización como partida propia en zonas húmedas (cocina/baño/integral)
- Extras fijos que no escalan con m²: boletín (siempre), tuberías (piso anterior
  a 2000) y cambio de distribución (mover inodoro/ducha/bañera)
- Intensidad por tipo en fontanería/electricidad (baseline cocina) para que un
  integral no escale como un baño
- Factor de zona por provincia en tramos (Madrid/BCN 1.40, islas 1.30, capitales
  1.20, rural 0.85, resto 1.00)
- 2 preguntas nuevas en el formulario del cliente para disparar los extras
- Panel de precios: campo de impermeabilización + sección de extras fijos
- Seed recalibrado (mano de obra, extras, catálogo suelo/pared)
- Migración 0009 (leads.anterior_a_2000, leads.cambio_distribucion, pricing_config.extras)
- Tests del motor ampliados (impermeabilización, extras, intensidad por tipo)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:02:57 +02:00
Carlos Narro
daa58c39a1 Ignora la colección Postman del EP (lleva la FUNNEL_API_KEY embebida)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:04:23 +02:00
unknown
aee82267d0 Configuracion de reglas de typescript 2026-06-03 23:14:53 -04:00
unknown
f082351b43 actualizacion de readme agente Luisa 2026-06-03 23:09:37 -04:00
23 changed files with 2490 additions and 137 deletions

View File

@@ -582,47 +582,54 @@ Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel
Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo,
igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente.
Diseño: HTML transaccional mobile-first, una columna (máx. 600px), dark mode, botón "bulletproof"
(tabla), tipografías de sistema, color de acento = color de marca del reformista. Cada email lleva
asunto (≤50 car.), preheader (texto de previsualización) y versión en texto plano.
### Email de entrega del presupuesto (PDF adjunto)
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto.
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. Tono: la entrega es el
protagonista, cálido pero claro.
- **Asunto:** *Tu presupuesto de reforma con [Reformista] ya está listo*
- **Asunto (elegido):** *Aquí está tu presupuesto de reforma*
- **Asunto (alt. A):** *Tu reforma, en números y en imágenes*
- **Asunto (alt. B):** *[Reformista]: tu presupuesto ya está listo*
- **Preheader:** *Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).*
- **Headline:** *Aquí está tu presupuesto, [Nombre]*
- **Cuerpo:**
> Hola [Nombre],
> Hemos preparado el **presupuesto orientativo** de tu reforma. En el PDF adjunto tienes el render de
> cómo quedaría tu espacio y el desglose por partidas.
>
> Aquí tienes tu **presupuesto orientativo de reforma**, preparado por [Reformista]. Lo encontrarás
> adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.
> *Es una estimación.* El precio definitivo lo confirma **[Reformista]** en una **visita gratuita** en
> tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.
>
> ⚠️ Es una **estimación**. El precio definitivo lo confirma [Reformista] en una visita gratuita en
> tu casa, donde mide todo con detalle y lo ajusta.
>
> Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin
> compromiso.
>
> —
> [Reformista]
> ¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.
- **CTA (si hay teléfono/email del reformista):** *Agendar mi visita gratuita*
- **Footer:** *Presupuesto orientativo. El precio final puede variar según la visita técnica. · [Reformista]*
### Email con enlace al formulario (subir imágenes)
Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos
del espacio. `[url]` apunta a su formulario personal del funnel.
del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola acción clara.
- **Asunto:** *Sube las fotos de tu reforma para [Reformista]*
- **Asunto (elegido):** *Sube las fotos de tu reforma*
- **Asunto (alt. A):** *Un paso más para tu presupuesto*
- **Asunto (alt. B):** *[Reformista] necesita ver tu espacio*
- **Preheader:** *Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.*
- **Headline:** *Enséñanos tu espacio, [Nombre]*
- **Cuerpo:**
> Hola [Nombre],
> Para preparar tu render y tu presupuesto, **[Reformista]** necesita ver cómo está ahora tu espacio.
>
> Para preparar tu render y tu presupuesto, [Reformista] necesita ver el espacio. Sube unas fotos
> de cada zona desde este enlace, cuando te venga bien:
> Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que
> quieras.
>
> 👉 [Subir mis fotos]([url])
>
> Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu
> presupuesto.
>
> —
> [Reformista]
> En cuanto las tengamos, seguimos con tu presupuesto.
- **CTA:** *Subir mis fotos*`[url]`
- **Footer:** *[Reformista]*
---

View File

@@ -1,31 +1,165 @@
# Reformix Luisa Bot 🤖
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional, recoge 5 datos clave y cierra el flujo según el flag viable/no_viable.
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional siguiendo una máquina de estados de 7 pasos, recoge 5 datos clave (espacio, metros, estilo, urgencia, presupuesto) y cierra el flujo según el flag viable/no_viable.
---
## Stack
- **NestJS** — framework principal
- **Baileys** — conexión con WhatsApp (sin API oficial)
- **PostgreSQL** — base de datos via TypeORM
- **Claude 4.5** via **OpenRouter** — LLM con soporte de texto, audio e imagen
| Capa | Tecnología |
|------|-----------|
| **Framework** | NestJS 10 |
| **WhatsApp** | Baileys 7 (`@whiskeysockets/baileys`) + `baileys-antiban` |
| **Base de datos** | PostgreSQL via TypeORM con `synchronize: true` (dev) / migrations (prod) |
| **LLM** | Claude 4.5 Sonnet/Haiku + Gemini 2.5 Flash via **OpenRouter** |
| **Logging** | Pino |
| **QR** | `qrcode-terminal` |
---
## Estructura del proyecto
```
/src
/whatsapp ← Módulo Baileys: conexión, QR, recepción y envío
/leads ← Módulo de leads: CRUD y lógica de estados
/conversacion ← Módulo de historial de mensajes por lead
/scheduler ← Cron cada 5 min: dispara apertura a leads nuevos
/claude ← Construye el contexto y llama a Claude 4.5
/media ← Procesa audio e imagen antes de pasar a Claude
/prompts
luisa_core.md ← Identidad y personalidad de Luisa ← RELLENAR
luisa_flujo.md ← Flujo de cualificación paso a paso ← RELLENAR
luisa_casos.md ← Casos edge y ejemplos ← RELLENAR
/
├── auth_info_baileys/ ← Estado de sesión de WhatsApp (se genera automáticamente)
├── dist/ ← Compilación
├── node_modules/
├── prompts/ ← Prompts del sistema para Claude
├── luisa_core.md ← Identidad, personalidad y máquina de estados
├── luisa_flujo.md ← Flujo de cualificación paso a paso
│ └── luisa_casos.md ← Casos edge y ejemplos
├── src/
├── main.ts ← Punto de entrada
├── app.module.ts ← Módulo raíz con TypeORM y Schedule
├── whatsapp/
│ │ ├── whatsapp.module.ts
│ │ ├── whatsapp.service.ts ← Conexión Baileys, recepción/envío
│ │ └── whatsapp-debounce.service.ts ← Debounce de 3s para coalescer mensajes rápidos
│ ├── leads/
│ │ ├── leads.module.ts
│ │ ├── lead.entity.ts ← Entidad Lead con todos los campos
│ │ └── leads.service.ts ← CRUD, máquina de estados, viabilidad
│ ├── conversacion/
│ │ ├── conversacion.module.ts
│ │ ├── conversacion.entity.ts ← Entidad Conversacion (historial)
│ │ └── conversacion.service.ts ← Guardado y recuperación de historial
│ ├── claude/
│ │ ├── claude.module.ts
│ │ └── claude.service.ts ← Arquitectura de 4 capas con Claude
│ ├── media/
│ │ ├── media.module.ts
│ │ └── media.service.ts ← Transcripción de audio + análisis de imagen
│ └── scheduler/
│ ├── scheduler.module.ts
│ └── scheduler.service.ts ← Cron cada 5 min: apertura a leads nuevos + limpieza
├── .env.example
├── nest-cli.json
├── package.json
├── tsconfig.json
└── tsconfig.build.json
```
---
## Arquitectura de procesamiento (4 capas con Claude)
Cada mensaje entrante pasa por 4 capas antes de responder:
```
Mensaje entrante (texto / audio / imagen)
┌───────────────────────────────┐
│ PREPROCESAMIENTO │
│ • Identificar lead por teléfono│
│ • Si audio → transcripción │
│ (Gemini 2.5 Flash via │
│ OpenRouter) │
│ • Si imagen → Vision │
│ (Claude Sonnet via │
│ OpenRouter) │
│ • Si texto → directo │
│ • Guardar mensaje usuario en │
│ DB │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 1: CLASIFICADOR (Haiku) │
│ Extrae intención y valor │
│ del mensaje: │
│ • responde_pregunta: bool │
│ • valor_extraido: string|null │
│ • es_desvio: bool │
│ • intencion: respuesta | │
│ desvio | despedida | │
│ insulto | pregunta │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 2: VALIDADOR (código) │
│ Valida contra valores │
│ permitidos del estado actual │
│ • espacios: cocina, bano, │
│ salon, integral, otro │
│ • tamaño: menos10, 10a20, │
│ 20a40, mas40 │
│ • estilo: funcional, cuidado, │
│ exclusivo │
│ • urgencia: urgente, │
│ medio_plazo, frio │
│ • presupuesto: evalúa │
│ viabilidad (>= 5000€) │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 3: GENERADOR (Sonnet) │
│ Construye contexto y genera │
│ borrador de respuesta │
│ • Estado del lead │
│ • Datos capturados │
│ • Historial completo │
│ • Clasificación y validación │
│ • Reintentos │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 4: REGLAS (Haiku) │
│ Corrige el borrador para │
│ cumplir identidad de Luisa: │
│ • Sin menciones a IA │
│ • Máximo 2 líneas │
│ • Sin emojis │
│ • Tono Madrid/España │
└───────────┬───────────────────┘
Guardar respuesta en DB
Enviar por Baileys
```
---
## Variables de entorno
### `.env.example`
```env
OPENROUTER_API_KEY= # (REQUERIDA) API key de OpenRouter
MODEL_GENERADOR=anthropic/claude-sonnet-4-5 # Modelo para generar respuestas (Capa 3)
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 # Modelo para clasificar mensajes (Capa 1)
MODEL_REGLAS=anthropic/claude-haiku-4-5 # Modelo para aplicar reglas (Capa 4)
MODEL_TRANSCRIPCION=google/gemini-2.5-flash # Modelo para transcripción de audio
MODEL=anthropic/claude-sonnet-4-5 # Fallback general si no se especifica otro
DATABASE_URL= # (REQUERIDA) PostgreSQL connection string
ALLOWED_NUMBER= # (OPCIONAL) Restringe el bot a un solo número
```
**Notas:**
- `ALLOWED_NUMBER` es opcional. Si se define, el bot **solo atiende mensajes de ese número**. Si se deja vacío, acepta mensajes de cualquier número de teléfono.
- La transcripción de audio usa **Gemini 2.5 Flash** por defecto porque Claude no soporta `input_audio` en OpenRouter. El resto del pipeline sigue usando Claude.
- Si `MODEL` está definido, sirve como fallback para `MODEL_GENERADOR` cuando no se especifica.
---
## Configuración rápida
### 1. Variables de entorno
@@ -34,32 +168,33 @@ Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de
cp .env.example .env
```
Edita `.env`:
Edita `.env` con tus valores reales:
```env
OPENROUTER_API_KEY=sk-or-...
MODEL=anthropic/claude-sonnet-4-5
DATABASE_URL=postgresql://user:password@localhost:5432/reformix_luisa
OPENROUTER_API_KEY=sk-or-v1-tu-api-key-aqui
DATABASE_URL=postgresql://user:password@localhost:5432/reformix
```
### 2. Base de datos
El proyecto usa `synchronize: true` en modo desarrollo, TypeORM creará las tablas automáticamente al arrancar.
El proyecto usa `synchronize: true` en modo desarrollo (definido en `src/app.module.ts`). TypeORM creará las tablas automáticamente al arrancar.
En producción, desactiva `synchronize` y usa migrations:
En producción, cambia `synchronize: false` y usa migrations:
```bash
npm run migration:generate
npm run migration:generate -- src/migrations/Init
npm run migration:run
```
### 3. Prompts de Luisa
Rellena los 3 archivos en `/prompts` antes de arrancar:
Los 3 archivos de prompts ya están creados y contienen la configuración completa:
- `luisa_core.md`identidad, tono, límites
- `luisa_flujo.md`estados, preguntas por estado, condiciones de avance
- `luisa_casos.md`casos edge, fallbacks, ejemplos de conversación
- **`prompts/luisa_core.md`**Identidad de Luisa, personalidad (español de Madrid, cercana, directa), máquina de estados obligatoria con mensajes exactos por estado, extracción de datos con bloque JSON `<DATOS_EXTRAIDOS>`, manejo de casos especiales (desvíos, reintentos, inactividad, multimedia, tono defensivo), y ejemplos few-shot.
- **`prompts/luisa_flujo.md`**Secuencia de estados, campos DB con valores permitidos, y mensajes por estado (versión simplificada).
- **`prompts/luisa_casos.md`**Casos edge: desvíos, reintentos (máx 2), inactividad (24h/48h), manejo de audio/imagen/sticker, tono defensivo, usuario que no da presupuesto.
Puedes modificarlos libremente para ajustar el tono o comportamiento de Luisa.
### 4. Arrancar
@@ -68,61 +203,139 @@ npm install
npm run start:dev
```
Escanea el **QR** que aparece en la terminal con WhatsApp.
Aparecerá un **código QR** en la terminal. Escanéalo con WhatsApp**WhatsApp Web**.
Luisa queda conectada y lista.
Luisa queda conectada y lista para recibir mensajes.
## Flujo de mensajes
---
```
Mensaje entrante (texto / audio / imagen)
Identificar lead por teléfono (crear si no existe)
Si audio → Claude 4.5 transcripción
Si imagen → Claude 4.5 Vision (prompt según estado)
Si texto → directo
Guardar mensaje usuario en DB
Construir contexto: estado, datos del lead, historial, prompts MD
Llamar Claude 4.5 via OpenRouter
Extraer entidades del turno → actualizar lead en DB
Evaluar flag viable → cambiar estado si aplica
Guardar respuesta de Claude en DB
Enviar respuesta por Baileys
```
## Scheduler (cron cada 5 min)
- Busca leads con `estado_actual = 'nuevo'`
- Marca como `en_proceso` antes de actuar
- Genera y envía el mensaje de APERTURA de Luisa
- Ignora leads en `completado`, `no_viable`, `perdido`
- Marca como `perdido` leads en `en_proceso` sin actividad > 48h
## Estados del lead
## Máquina de estados del lead
| Estado | Descripción |
|--------|-------------|
| `nuevo` | Lead creado, aún no contactado |
| `en_proceso` | Luisa le ha enviado el primer mensaje |
| `recopilando_datos` | Conversación activa |
| `completado` | Todos los datos recogidos, viable=true |
| `no_viable` | Lead descartado, viable=false |
| `en_proceso` | El scheduler le ha enviado el primer mensaje (transición interna) |
| `apertura` | Luisa se presenta y pregunta disponibilidad |
| `espacio` | Pregunta: ¿qué espacio quieres reformar? |
| `tamano` | Pregunta: ¿rango de metros cuadrados? |
| `estilo` | Pregunta: ¿tipo de acabado? |
| `urgencia` | Pregunta: ¿cuándo quieres empezar? |
| `presupuesto` | Pregunta: ¿presupuesto aproximado? |
| `fin_viable` | Transición interna → `completado` si presupuesto >= 5000€ |
| `fin_no_viable` | Transición interna → `no_viable` si presupuesto < 5000€ |
| `recopilando_datos` | Estado legacy, se normaliza a `apertura` |
| `completado` | Todos los datos recogidos y lead viable |
| `no_viable` | Lead descartado por presupuesto insuficiente |
| `perdido` | Sin actividad > 48h |
**Secuencia completa:** `nuevo → apertura → espacio → tamano → estilo → urgencia → presupuesto → fin_viable/fin_no_viable`
### Datos recolectados por estado
| Estado | Campo DB | Valores válidos |
|--------|----------|-----------------|
| `espacio` | `espacio` | `cocina`, `bano`, `salon`, `integral`, `otro` |
| `tamano` | `rango_m2` | `menos10`, `10a20`, `20a40`, `mas40` |
| `estilo` | `estilo` | `funcional`, `cuidado`, `exclusivo` |
| `urgencia` | `urgencia` | `urgente`, `medio_plazo`, `frio` |
| `presupuesto` | `presupuesto_declarado` | Cifra o rango en euros |
### Evaluación de viabilidad
Un presupuesto es **viable** si su valor numérico es **>= 5000€**. Si no se puede determinar el valor, se asume viable.
---
## Scheduler (cron cada 5 min)
El servicio `SchedulerService` se ejecuta cada 5 minutos vía `@nestjs/schedule` y realiza dos tareas:
1. **Limpiar leads inactivos**: Marca como `perdido` los leads en `en_proceso` sin actualización > 48h.
2. **Apertura de leads nuevos**: Busca leads con `estado_actual = 'nuevo'`, los marca como `en_proceso`, llama a Claude para generar el mensaje de apertura, lo guarda en el historial y lo envía por WhatsApp.
---
## Debounce de mensajes
El servicio `WhatsappDebounceService` agrupa mensajes rápidos de un mismo usuario en una ventana de **3 segundos**. Si el usuario envía varios mensajes cortos seguidos, se concatenan en un solo texto antes de procesarlos. Esto evita que Claude reciba múltiples requests simultáneas por el mismo lead.
---
## Restricción por número (ALLOWED_NUMBER)
Si se define `ALLOWED_NUMBER` en `.env`, el bot **ignora cualquier mensaje que no venga de ese número**. Esto es útil para pruebas o para desplegar una instancia dedicada a un solo cliente.
---
## Soporte multimedia
### Audio
- Se descarga el buffer del mensaje de audio via Baileys.
- Se detecta el formato real por magic bytes (Ogg, MP3, WAV).
- Se envía a OpenRouter con `input_audio` usando **Gemini 2.5 Flash** (modelo configurable via `MODEL_TRANSCRIPCION`).
- La transcripción conserva coloquialismos y jerga madrileña.
- Si falla o el buffer es muy pequeño, se responde con un mensaje de cortesía pidiendo repetición.
### Imagen
- Se descarga el buffer y se envía a OpenRouter con `image_url` usando **Claude Sonnet**.
- El prompt de análisis varía según el estado actual del lead:
- En `espacio`/`tamano`: infiere tipo de espacio y metros cuadrados.
- En `estilo`: infiere el estilo o calidad de acabados.
- En otros estados: descripción general.
- Si la imagen tiene caption, se combina con la inferencia.
---
## Manejo de errores y reconexión
- Si la conexión de WhatsApp se cierra por cualquier motivo que **no sea logout**, se reintenta automáticamente tras 5 segundos.
- Si hay un **logout** (sesión cerrada), se muestra un error pidiendo eliminar `auth_info_baileys` y reiniciar.
- Cada mensaje se procesa en un bloque `try/catch`; si falla, se loguea el error y se continúa con el siguiente mensaje.
- Si Claude falla repetidamente al clasificar, se usa un fallback conservador (desvío) para no bloquear la conversación.
- Si la respuesta generada contiene frases prohibidas ("soy un asistente", "ChatGPT", etc.), se reemplaza automáticamente con un `mensajeFallback` según el estado actual.
---
## Scripts disponibles
```bash
npm run build # Compilar con NestJS
npm run start # Iniciar en producción
npm run start:dev # Iniciar en desarrollo con watch
npm run start:debug # Iniciar en modo debug
npm run lint # Ejecutar ESLint
npm run test # Ejecutar tests con Jest
npm run migration:generate # Generar migración de TypeORM
npm run migration:run # Ejecutar migraciones pendientes
```
---
## Qué NO hace este servicio
- No genera el presupuesto (lo hace otro worker)
- No renderiza el PDF
- No envía la URL (la inserta el worker en `url_presupuesto`)
- No tiene panel del reformista
- No genera el presupuesto (lo hace otro worker externo)
- No renderiza el PDF
- No envía la URL del presupuesto (la inserta el worker en `url_presupuesto`)
- No tiene panel del reformista
- ❌ No maneja conversaciones grupales (@g.us) — se ignoran explícitamente
---
## Desarrollo
### Requisitos
- Node.js >= 20
- PostgreSQL >= 14
- Cuenta en [OpenRouter](https://openrouter.ai) con API key
### Tests
```bash
npm run test
npm run test:watch # Modo watch
npm run test:cov # Con cobertura
```
---

View File

@@ -1,6 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"ignoreDeprecations": "6.0",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
@@ -9,6 +10,7 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,

3
mvp/b2c/.gitignore vendored
View File

@@ -43,3 +43,6 @@ next-env.d.ts
# Colección Postman con la FUNNEL_API_KEY embebida — no commitear
api-docs/reformix-ingesta.postman_collection.json
# Logs locales del dev server
dev.log

View File

@@ -0,0 +1,3 @@
ALTER TABLE "leads" ADD COLUMN "anterior_a_2000" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "cambio_distribucion" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "pricing_config" ADD COLUMN "extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1780505942614,
"tag": "0008_sharp_bloodaxe",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780569557328,
"tag": "0009_white_agent_brand",
"breakpoints": true
}
]
}

View File

@@ -66,6 +66,7 @@ export async function actualizarConfig(formData: FormData) {
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
manoObra: {
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
impermeabilizacion: eurosToCents(formData.get('mo_impermeabilizacion'), 'impermeabilización'),
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
@@ -76,6 +77,22 @@ export async function actualizarConfig(formData: FormData) {
revalidatePath('/panel/precios');
}
export async function actualizarExtras(formData: FormData) {
const tenantId = await getTenantId();
await db
.update(pricingConfig)
.set({
extras: {
tuberias: eurosToCents(formData.get('extra_tuberias'), 'renovación de tuberías'),
boletin: eurosToCents(formData.get('extra_boletin'), 'boletín eléctrico'),
distribucion: eurosToCents(formData.get('extra_distribucion'), 'cambio de distribución'),
},
updatedAt: new Date(),
})
.where(eq(pricingConfig.tenantId, tenantId));
revalidatePath('/panel/precios');
}
export async function actualizarEnvio(formData: FormData) {
const tenantId = await getTenantId();
const modo = formData.get('modo');

View File

@@ -4,6 +4,7 @@ import {
actualizarPrecio,
borrarMaterial,
actualizarConfig,
actualizarExtras,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
@@ -96,6 +97,7 @@ export default async function PreciosPage() {
{(
[
['demolicion', 'Demolición'],
['impermeabilizacion', 'Impermeabilización'],
['fontaneria', 'Fontanería'],
['electricidad', 'Electricidad'],
['mano_de_obra', 'Mano de obra'],
@@ -118,6 +120,39 @@ export default async function PreciosPage() {
</form>
</section>
{/* Extras fijos */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
<p className="text-sm text-gray-500 mb-4">
Importes fijos que no escalan con los metros. El boletín eléctrico se aplica siempre; las
tuberías solo en pisos anteriores al año 2000 y la distribución al mover inodoro, ducha o
bañera.
</p>
<form action={actualizarExtras} className="grid grid-cols-2 md:grid-cols-3 gap-3 items-end">
{(
[
['tuberias', 'Renovación de tuberías'],
['boletin', 'Boletín eléctrico'],
['distribucion', 'Cambio de distribución'],
] as const
).map(([k, etiqueta]) => (
<label key={k} className="text-sm">
<span className="block text-xs text-gray-500 mb-1">{etiqueta} ()</span>
<input
name={`extra_${k}`}
type="number"
step="0.01"
defaultValue={(config.extras?.[k] ?? 0) / 100}
className="w-full border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
))}
<button className="col-span-2 md:col-span-3 justify-self-start bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar extras
</button>
</form>
</section>
{/* Catálogo por categoría */}
{CATEGORIAS.map((categoria) => {
const items = catalog.filter((c) => c.categoria === categoria);

View File

@@ -14,6 +14,7 @@ import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
import { enviarEnlaceFormulario } from '@/lib/email/mailer';
import { resolveTheme } from '@/lib/funnel/themes';
import { env } from '@/lib/env';
const MAX_ZONAS = 6;
@@ -146,6 +147,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
const presupuestoTarget =
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
const estructural = formData.get('estructural') === 'on';
const anteriorA2000 = formData.get('anteriorA2000') === 'on';
const cambioDistribucion = formData.get('cambioDistribucion') === 'on';
let zonas = await parsearZonas(formData);
if (zonas.length === 0) {
@@ -191,6 +194,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
urgencia,
presupuestoTarget,
estructural,
anteriorA2000,
cambioDistribucion,
tasteText,
pipelineStage: 'fotos_subidas',
updatedAt: new Date(),
@@ -252,12 +257,19 @@ export async function enviarEnlaceFormularioEmail(leadId: string): Promise<boole
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return false;
const tenant = await getTenantPerfilById(lead.tenantId);
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
return enviarEnlaceFormulario({
to: lead.email,
nombre: lead.nombre,
empresa: tenant.nombreEmpresa,
url,
brand: {
primary: theme.primary,
primaryDark: theme.primaryDark,
contrast: theme.contrast,
logoUrl: tenant.logoUrl,
},
});
}

View File

@@ -8,11 +8,27 @@ import type {
CatalogItem,
PartidaKey,
PricingConfig,
TipoReforma,
} from './types';
const LICENCIA_MIN = 30000; // 300 €
const LICENCIA_MAX = 150000; // 1.500 €
// Zonas húmedas: las únicas que llevan impermeabilización.
const WET = new Set<TipoReforma>(['cocina', 'bano', 'integral']);
// Intensidad de instalaciones (fontanería/electricidad) por m² según el tipo de reforma.
// Baseline cocina = 1.0. Un baño concentra más instalaciones por m²; un salón o un piso
// integral las diluye. Corrige el sesgo del modelo lineal €/m² sin rehacerlo.
const TIPO_INTENSIDAD: Record<TipoReforma, number> = {
cocina: 1.0,
bano: 1.3,
integral: 0.45,
salon: 0.4,
comedor: 0.4,
otro: 0.7,
};
// A qué partida contribuye el material de cada categoría.
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
suelo: 'alicatado',
@@ -34,12 +50,14 @@ export function computeBudget(
const importes: Record<PartidaKey, number> = {
demolicion: 0,
impermeabilizacion: 0,
alicatado: 0,
fontaneria: 0,
electricidad: 0,
carpinteria: 0,
mano_de_obra: 0,
extras: 0,
extras_fijos: 0,
licencia: 0,
};
@@ -67,11 +85,25 @@ export function computeBudget(
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
}
const intensidad = TIPO_INTENSIDAD[inputs.tipoReforma] ?? 1;
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria);
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad);
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria * intensidad);
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad * intensidad);
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
// Impermeabilización: solo en zonas húmedas, proporcional al suelo a tratar.
if (WET.has(inputs.tipoReforma)) {
importes.impermeabilizacion += Math.round(cant.m2Suelo * config.manoObra.impermeabilizacion);
}
// Extras fijos (no escalan con m²). El boletín eléctrico es siempre obligatorio.
const extras = config.extras;
if (extras) {
importes.extras_fijos += extras.boletin;
if (inputs.anteriorA2000) importes.extras_fijos += extras.tuberias;
if (inputs.cambioDistribucion) importes.extras_fijos += extras.distribucion;
}
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({

View File

@@ -2,22 +2,26 @@ import type { PartidaKey } from './types';
export const PARTIDA_ORDER: PartidaKey[] = [
'demolicion',
'impermeabilizacion',
'alicatado',
'fontaneria',
'electricidad',
'carpinteria',
'mano_de_obra',
'extras',
'extras_fijos',
'licencia',
];
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
demolicion: 'Demolición',
impermeabilizacion: 'Impermeabilización',
alicatado: 'Alicatado y solado',
fontaneria: 'Fontanería',
electricidad: 'Electricidad',
carpinteria: 'Carpintería y mobiliario',
mano_de_obra: 'Mano de obra',
extras: 'Pintura y extras',
extras_fijos: 'Extras (tuberías, boletín, distribución)',
licencia: 'Licencia + Proyecto técnico',
};

View File

@@ -5,15 +5,22 @@ export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' |
export type PartidaKey =
| 'demolicion'
| 'impermeabilizacion'
| 'alicatado'
| 'fontaneria'
| 'electricidad'
| 'carpinteria'
| 'mano_de_obra'
| 'extras'
| 'extras_fijos'
| 'licencia';
export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra';
export type ManoObraKey =
| 'demolicion'
| 'impermeabilizacion'
| 'fontaneria'
| 'electricidad'
| 'mano_de_obra';
export interface CatalogItem {
id: string;
@@ -27,10 +34,18 @@ export interface CatalogItem {
sku: string;
}
// Extras fijos que no escalan con los m² (céntimos). Se aplican según el estado del piso.
export interface ExtrasFijos {
tuberias: number; // renovación de tuberías (pisos anteriores a 2000)
boletin: number; // boletín eléctrico (siempre obligatorio)
distribucion: number; // cambio de distribución (mover inodoro/ducha/bañera)
}
export interface PricingConfig {
alturaTechoDefault: number; // metros
factorZona: Record<string, number>; // provincia -> multiplicador
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
extras?: ExtrasFijos; // importes fijos en céntimos
}
export interface BudgetInputs {
@@ -41,6 +56,8 @@ export interface BudgetInputs {
estructural: boolean;
provincia: string | null;
materialSelections: Partial<Record<CategoriaMaterial, string>>; // categoria -> catalogItemId
anteriorA2000?: boolean; // dispara el extra de renovación de tuberías
cambioDistribucion?: boolean; // dispara el extra de cambio de distribución
}
export interface PartidaResult {

View File

@@ -271,10 +271,20 @@ export default function FormularioZonas({
</div>
</div>
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
Hay que mover sanitarios, tirar algún muro o cambiar la distribución
</label>
<div className="flex flex-col gap-3">
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
Hay que tirar algún muro u obra estructural
</label>
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
<input type="checkbox" name="cambioDistribucion" className="w-4 h-4 accent-black" />
Hay que mover el inodoro, la ducha o la bañera
</label>
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
<input type="checkbox" name="anteriorA2000" className="w-4 h-4 accent-black" />
El piso es anterior al año 2000
</label>
</div>
</div>
<SubmitButton />

View File

@@ -21,11 +21,14 @@ export async function getEnvioMode(): Promise<EnvioMode> {
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
demolicion: 0,
impermeabilizacion: 0,
fontaneria: 0,
electricidad: 0,
mano_de_obra: 0,
};
const EXTRAS_DEFAULT = { tuberias: 0, boletin: 0, distribucion: 0 };
export async function getPricingConfigFor(tenantId: string): Promise<PricingConfig> {
const [row] = await db
.select()
@@ -34,12 +37,18 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
.limit(1);
if (!row) {
return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } };
return {
alturaTechoDefault: 2.5,
factorZona: {},
manoObra: { ...MANO_OBRA_DEFAULT },
extras: { ...EXTRAS_DEFAULT },
};
}
return {
alturaTechoDefault: row.alturaTechoDefault,
factorZona: row.factorZona,
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
};
}

View File

@@ -199,6 +199,9 @@ export const leads = pgTable(
alturaTecho: doublePrecision('altura_techo'),
calidadGlobal: calidad('calidad_global'),
estructural: boolean('estructural').notNull().default(false),
// Inputs de los extras fijos del presupuesto (no escalan con m²).
anteriorA2000: boolean('anterior_a_2000').notNull().default(false),
cambioDistribucion: boolean('cambio_distribucion').notNull().default(false),
materialSelections: jsonb('material_selections')
.$type<Record<string, string>>()
.notNull()
@@ -340,6 +343,11 @@ export const pricingConfig = pgTable('pricing_config', {
alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5),
factorZona: jsonb('factor_zona').$type<Record<string, number>>().notNull().default({}),
manoObra: jsonb('mano_obra').$type<Record<string, number>>().notNull().default({}),
// Extras fijos en céntimos: { tuberias, boletin, distribucion }.
extras: jsonb('extras')
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
.notNull()
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

View File

@@ -17,6 +17,26 @@ const db = drizzle(client, { schema });
const euros = (n: number) => Math.round(n * 100); // a céntimos
// Factor de zona geográfica por provincia/ciudad. Las no listadas valen 1.0 (media nacional).
// Tramos: Madrid/Barcelona 1.40, islas 1.30, capitales grandes 1.20, rural/interior 0.85.
const ZONA_FACTORES: Record<string, number> = Object.fromEntries(
[
[['Madrid', 'Barcelona'], 1.4],
[
['Baleares', 'Islas Baleares', 'Palma', 'Mallorca', 'Las Palmas', 'Tenerife', 'Santa Cruz de Tenerife', 'Canarias'],
1.3,
],
[
['Valencia', 'Sevilla', 'Málaga', 'Bilbao', 'Vizcaya', 'Bizkaia', 'Zaragoza', 'Alicante', 'Murcia', 'San Sebastián', 'Gipuzkoa', 'Vitoria', 'Granada', 'Valladolid'],
1.2,
],
[
['Cuenca', 'Teruel', 'Soria', 'Zamora', 'Ávila', 'Palencia', 'Ourense', 'Lugo', 'Cáceres', 'Badajoz', 'Ciudad Real', 'Albacete', 'Jaén', 'Huesca', 'Segovia', 'Guadalajara'],
0.85,
],
].flatMap(([nombres, factor]) => (nombres as string[]).map((n) => [n, factor as number])),
);
// Cada lead vive en un momento distinto del funnel para poder analizar
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
type SeedLead = {
@@ -513,8 +533,15 @@ async function main() {
.values({
tenantId: tenantRow.id,
alturaTechoDefault: 2.5,
factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 },
manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 },
factorZona: ZONA_FACTORES,
manoObra: {
demolicion: 5000,
impermeabilizacion: 4500,
fontaneria: 14600,
electricidad: 5400,
mano_de_obra: 7500,
},
extras: { tuberias: 115000, boletin: 17500, distribucion: 90000 },
})
.returning();
@@ -539,12 +566,12 @@ async function main() {
});
const catalog = await db.insert(schema.catalogItems).values([
cat('suelo', 'Gres cerámico básico', 'basica', 16, 'm2', 'suelo gres beige liso', 'SUE-B'),
cat('suelo', 'Porcelánico símil madera', 'media', 28, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
cat('suelo', 'Porcelánico gran formato', 'premium', 48, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
cat('pared', 'Azulejo blanco brillo', 'basica', 14, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
cat('pared', 'Azulejo rectificado', 'media', 24, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
cat('pared', 'Porcelánico decorativo', 'premium', 42, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
cat('suelo', 'Gres cerámico básico', 'basica', 40, 'm2', 'suelo gres beige liso', 'SUE-B'),
cat('suelo', 'Porcelánico símil madera', 'media', 70, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
cat('suelo', 'Porcelánico gran formato', 'premium', 170, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
cat('pared', 'Azulejo blanco brillo', 'basica', 32, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
cat('pared', 'Azulejo rectificado', 'media', 60, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
cat('pared', 'Porcelánico decorativo', 'premium', 140, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'),
cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'),
cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),

View File

@@ -23,6 +23,154 @@ function escapeHtml(s: string): string {
);
}
const primerNombre = (nombre: string) => nombre.split(' ')[0] || nombre;
// Color de marca del reformista para el acento del email (botón, barra, enlaces).
export type EmailBrand = {
primary: string;
primaryDark: string;
contrast: string;
logoUrl?: string | null;
};
const BRAND_DEFECTO: EmailBrand = { primary: '#0a0a0a', primaryDark: '#1a1a1a', contrast: '#ffffff' };
type EmailParts = {
brand: EmailBrand;
empresa: string;
preheader: string;
headline: string;
parrafosHtml: string[]; // HTML seguro: las variables ya vienen escapadas
cta?: { url: string; label: string } | null;
footer: string;
logoCid?: string | null; // si hay logo, se incrusta como adjunto inline (cid:)
};
type LogoAdjunto = { filename: string; content?: Buffer; path?: string; cid: string; contentType?: string };
const LOGO_CID = 'logoempresa';
// Prepara el logo como adjunto inline (CID) para que se vea en todos los clientes (Gmail bloquea
// las imágenes en data URI directas). Acepta data URI (base64), URL http(s), o ruta /public
// (se vuelve absoluta con APP_URL). Devuelve null si no hay logo usable.
function prepararLogo(logoUrl: string | null | undefined): LogoAdjunto | null {
if (!logoUrl) return null;
if (logoUrl.startsWith('data:')) {
const coma = logoUrl.indexOf(',');
if (coma === -1) return null;
const mime = logoUrl.slice(5, coma).split(';')[0] || 'image/png';
const ext = mime.split('/')[1]?.replace('+xml', '') || 'png';
const content = Buffer.from(logoUrl.slice(coma + 1), 'base64');
return { filename: `logo.${ext}`, content, cid: LOGO_CID, contentType: mime };
}
if (/^https?:\/\//.test(logoUrl)) return { filename: 'logo', path: logoUrl, cid: LOGO_CID };
if (logoUrl.startsWith('/') && env.APP_URL) {
return { filename: 'logo', path: `${env.APP_URL}${logoUrl}`, cid: LOGO_CID };
}
return null;
}
const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// Email transaccional: una columna (máx 600px), mobile-first, dark mode, botón bulletproof (tabla),
// estilos inline como base + <style> para dark/responsive. Compatible con Gmail/Apple Mail/Outlook.
function construirEmailHtml(p: EmailParts): string {
const empresa = escapeHtml(p.empresa);
const logo = p.logoCid
? `<img src="cid:${p.logoCid}" alt="${empresa}" height="40" style="max-height:40px;max-width:200px;display:block;border:0;" />`
: `<span class="email-logo-name" style="font-size:18px;font-weight:700;color:#1a1a1a;letter-spacing:-0.2px;">${empresa}</span>`;
const cta = p.cta
? `<tr><td style="height:28px;line-height:28px;">&nbsp;</td></tr>
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">
<tr><td style="border-radius:10px;background:${p.brand.primary};">
<a class="btn-a" href="${escapeHtml(p.cta.url)}" target="_blank"
style="display:inline-block;padding:15px 30px;font-family:${FONT};font-size:16px;font-weight:600;line-height:1;color:${p.brand.contrast};text-decoration:none;border-radius:10px;">
${escapeHtml(p.cta.label)}
</a>
</td></tr>
</table>
</td></tr>`
: '';
const parrafos = p.parrafosHtml
.map(
(html) =>
`<tr><td class="email-text" style="font-family:${FONT};font-size:16px;line-height:1.65;color:#3f3f46;padding-bottom:16px;">${html}</td></tr>`,
)
.join('');
return `<!DOCTYPE html>
<html lang="es" style="margin:0;padding:0;">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>${empresa}</title>
<style>
@media (prefers-color-scheme: dark) {
.email-body { background:#0b0b0c !important; }
.email-card { background:#18181b !important; }
.email-head { background:#18181b !important; }
.email-title { color:#fafafa !important; }
.email-text { color:#d4d4d8 !important; }
.email-muted { color:#a1a1aa !important; }
.email-rule { border-color:#27272a !important; }
.email-logo-name { color:#fafafa !important; }
}
@media (max-width:600px) {
.email-card { width:100% !important; border-radius:0 !important; }
.email-pad { padding-left:24px !important; padding-right:24px !important; }
.btn-a { display:block !important; padding-left:0 !important; padding-right:0 !important; width:100%; box-sizing:border-box; text-align:center; }
}
</style>
</head>
<body class="email-body" style="margin:0;padding:0;background:#f4f4f5;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(p.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f4f4f5;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="email-card" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;">
<tr><td style="height:4px;line-height:4px;background:${p.brand.primary};font-size:0;">&nbsp;</td></tr>
<tr><td class="email-head email-pad" style="padding:24px 40px 8px 40px;">
${logo}
</td></tr>
<tr><td class="email-pad" style="padding:8px 40px 0 40px;">
<h1 class="email-title" style="margin:0 0 18px 0;font-family:${FONT};font-size:26px;line-height:1.25;font-weight:800;color:#18181b;letter-spacing:-0.4px;">${escapeHtml(p.headline)}</h1>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
${parrafos}
${cta}
</table>
</td></tr>
<tr><td class="email-pad" style="padding:28px 40px 0 40px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="email-rule" style="border-top:1px solid #e4e4e7;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="email-pad email-muted" style="padding:16px 40px 32px 40px;font-family:${FONT};font-size:13px;line-height:1.5;color:#71717a;">${escapeHtml(p.footer)}</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function construirEmailText(p: EmailParts): string {
const lineas = [p.headline, '', ...p.parrafosHtml.map(stripHtml), ''];
if (p.cta) lineas.push(`${p.cta.label}: ${p.cta.url}`, '');
lineas.push('—', p.footer);
return lineas.join('\n');
}
const stripHtml = (html: string) =>
html
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
// Email de entrega del presupuesto con el PDF adjunto (COPY-GUIDE §6.b). Best-effort: si no hay
// SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline.
export async function enviarPresupuestoEmail(opts: {
@@ -31,25 +179,46 @@ export async function enviarPresupuestoEmail(opts: {
empresa: string;
pdf: Buffer;
filename: string;
brand?: EmailBrand;
cta?: { url: string; label: string } | null;
}): Promise<boolean> {
const transport = getTransport();
if (!transport) return false;
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
const empresa = escapeHtml(opts.empresa);
const html = `<p>Hola ${nombre},</p>
<p>Aquí tienes tu <strong>presupuesto orientativo de reforma</strong>, preparado por ${empresa}. Lo encontrarás adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.</p>
<p>⚠️ Es una <strong>estimación</strong>. El precio definitivo lo confirma ${empresa} en una visita gratuita en tu casa, donde mide todo con detalle y lo ajusta.</p>
<p>Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin compromiso.</p>
<p>—<br/>${empresa}</p>`;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const logo = prepararLogo(opts.brand?.logoUrl);
const parts: EmailParts = {
brand: opts.brand ?? BRAND_DEFECTO,
empresa: opts.empresa,
preheader: 'Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).',
headline: `Aquí está tu presupuesto, ${primerNombre(opts.nombre)}`,
parrafosHtml: [
`Hemos preparado el <strong>presupuesto orientativo</strong> de tu reforma. En el PDF adjunto tienes el render de cómo quedaría tu espacio y el desglose por partidas.`,
`<em>Es una estimación.</em> El precio definitivo lo confirma ${empresaB} en una <strong>visita gratuita</strong> en tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.`,
`¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.`,
],
cta: opts.cta ?? null,
footer: `Presupuesto orientativo. El precio final puede variar según la visita técnica. · ${opts.empresa}`,
logoCid: logo ? logo.cid : null,
};
const attachments: Array<{
filename: string;
content?: Buffer;
path?: string;
cid?: string;
contentType?: string;
}> = [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }];
if (logo) attachments.push(logo);
try {
await transport.sendMail({
from: env.EMAIL_FROM,
to: opts.to,
subject: `Tu presupuesto de reforma con ${opts.empresa} ya está listo`,
html,
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
subject: 'Aquí está tu presupuesto de reforma',
html: construirEmailHtml(parts),
text: construirEmailText(parts),
attachments,
});
return true;
} catch (err) {
@@ -65,25 +234,36 @@ export async function enviarEnlaceFormulario(opts: {
nombre: string;
empresa: string;
url: string;
brand?: EmailBrand;
}): Promise<boolean> {
const transport = getTransport();
if (!transport) return false;
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
const empresa = escapeHtml(opts.empresa);
const url = encodeURI(opts.url);
const html = `<p>Hola ${nombre},</p>
<p>Para preparar tu render y tu presupuesto, ${empresa} necesita ver el espacio. Sube unas fotos de cada zona desde este enlace, cuando te venga bien:</p>
<p>👉 <a href="${url}">Subir mis fotos</a></p>
<p>Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu presupuesto.</p>
<p>—<br/>${empresa}</p>`;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const logo = prepararLogo(opts.brand?.logoUrl);
const parts: EmailParts = {
brand: opts.brand ?? BRAND_DEFECTO,
empresa: opts.empresa,
preheader: 'Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.',
headline: `Enséñanos tu espacio, ${primerNombre(opts.nombre)}`,
parrafosHtml: [
`Para preparar tu render y tu presupuesto, ${empresaB} necesita ver cómo está ahora tu espacio.`,
`Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que quieras.`,
`En cuanto las tengamos, seguimos con tu presupuesto.`,
],
cta: { url: encodeURI(opts.url), label: 'Subir mis fotos' },
footer: opts.empresa,
logoCid: logo ? logo.cid : null,
};
try {
await transport.sendMail({
from: env.EMAIL_FROM,
to: opts.to,
subject: `Sube las fotos de tu reforma para ${opts.empresa}`,
html,
subject: 'Sube las fotos de tu reforma',
html: construirEmailHtml(parts),
text: construirEmailText(parts),
attachments: logo ? [logo] : undefined,
});
return true;
} catch (err) {
@@ -91,3 +271,5 @@ export async function enviarEnlaceFormulario(opts: {
return false;
}
}
export { construirEmailHtml, construirEmailText };

View File

@@ -4,6 +4,8 @@ import { leads, leadPipelineEventos } from '@/db/schema';
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
import { enviarPresupuestoEmail } from '@/lib/email/mailer';
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
import { resolveTheme } from '@/lib/funnel/themes';
import { normalizarTelefonoEs } from '@/lib/voice/retell';
export type ResultadoFinalizar = {
ok: boolean;
@@ -27,6 +29,15 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
.where(eq(leads.id, leadId));
// Marca del reformista para el acento del email + CTA de contacto (WhatsApp o email).
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
const tel = tenant.telefono ? normalizarTelefonoEs(tenant.telefono) : null;
const cta = tel
? { url: `https://wa.me/${tel.replace('+', '')}`, label: 'Agendar mi visita gratuita' }
: tenant.email
? { url: `mailto:${tenant.email}`, label: 'Agendar mi visita gratuita' }
: null;
const [emailEnviado, whatsappSenal] = await Promise.all([
enviarPresupuestoEmail({
to: lead.email,
@@ -34,6 +45,13 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
empresa: tenant.nombreEmpresa,
pdf: buffer,
filename,
brand: {
primary: theme.primary,
primaryDark: theme.primaryDark,
contrast: theme.contrast,
logoUrl: tenant.logoUrl,
},
cta,
}),
notificarFlujoWhatsapp({
leadId,

View File

@@ -126,6 +126,8 @@ export async function procesarLead(leadId: string): Promise<void> {
m2Suelo: lead.m2Suelo ?? null,
alturaTecho: lead.alturaTecho ?? null,
provincia: lead.provincia ?? null,
anteriorA2000: lead.anteriorA2000,
cambioDistribucion: lead.cambioDistribucion,
});
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);

View File

@@ -6,6 +6,8 @@ interface LeadInputsSource {
m2Suelo: number | null;
alturaTecho: number | null;
provincia: string | null;
anteriorA2000?: boolean;
cambioDistribucion?: boolean;
}
export function mergeIntoBudgetInputs(
@@ -20,6 +22,8 @@ export function mergeIntoBudgetInputs(
estructural: prefs.estructural,
provincia: lead.provincia,
materialSelections: prefs.materialSelections,
anteriorA2000: lead.anteriorA2000 ?? false,
cambioDistribucion: lead.cambioDistribucion ?? false,
};
}

View File

@@ -5,7 +5,7 @@ import type { BudgetInputs, CatalogItem, PricingConfig } from '@/budget/types';
const config: PricingConfig = {
alturaTechoDefault: 2.5,
factorZona: { Madrid: 1.1 },
manoObra: { demolicion: 1500, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
manoObra: { demolicion: 1500, impermeabilizacion: 0, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
};
const catalog: CatalogItem[] = [
@@ -102,3 +102,50 @@ describe('computeBudget', () => {
expect(r.total).toBeGreaterThan(0);
});
});
// Config con impermeabilización y extras fijos activos, sin factor de zona (total = subtotal).
const configFull: PricingConfig = {
alturaTechoDefault: 2.5,
factorZona: {},
manoObra: { demolicion: 1500, impermeabilizacion: 2000, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
extras: { tuberias: 100000, boletin: 15000, distribucion: 80000 },
};
describe('computeBudget — impermeabilización y extras fijos', () => {
it('añade impermeabilización en zonas húmedas (baño) proporcional al suelo', () => {
const r = computeBudget(inputs({ tipoReforma: 'bano', m2Suelo: 5, provincia: null }), configFull, catalog);
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
expect(byKey.impermeabilizacion).toBe(10000); // 5 * 2000
});
it('no añade impermeabilización en zonas secas (salón)', () => {
const r = computeBudget(inputs({ tipoReforma: 'salon', provincia: null }), configFull, catalog);
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
expect(byKey.impermeabilizacion).toBeUndefined();
});
it('aplica siempre el boletín eléctrico como extra fijo', () => {
const r = computeBudget(inputs({ provincia: null }), configFull, catalog);
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
expect(byKey.extras_fijos).toBe(15000); // solo boletín
});
it('suma tuberías y distribución según los inputs del piso', () => {
const r = computeBudget(
inputs({ provincia: null, anteriorA2000: true, cambioDistribucion: true }),
configFull,
catalog,
);
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
expect(byKey.extras_fijos).toBe(195000); // 15000 + 100000 + 80000
});
it('la intensidad por tipo reduce la fontanería de un integral frente a una cocina', () => {
const cocina = computeBudget(inputs({ tipoReforma: 'cocina', m2Suelo: 16, provincia: null }), configFull, catalog);
const integral = computeBudget(inputs({ tipoReforma: 'integral', m2Suelo: 16, provincia: null }), configFull, catalog);
const fontCocina = cocina.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0;
const fontIntegral = integral.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0;
expect(fontCocina).toBe(19200); // 16 * 1200 * 1.0
expect(fontIntegral).toBe(8640); // 16 * 1200 * 0.45
});
});

View File

@@ -5,7 +5,7 @@ import type { BudgetInputs, PricingConfig } from '@/budget/types';
const config: PricingConfig = {
alturaTechoDefault: 2.5,
factorZona: {},
manoObra: { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 },
manoObra: { demolicion: 0, impermeabilizacion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 },
};
function inputs(partial: Partial<BudgetInputs>): BudgetInputs {