Compare commits

..

11 Commits

Author SHA1 Message Date
Carlos Narro
5df608f203 Muestra fotos y notas por zona en la ficha del lead
- agruparPorZona (lib/funnel/fotos.ts): helper puro que agrupa fotos
  (antes/después) y notas por zona, con fallback al tipo del lead. 5 tests.
- getLead trae también lead_notas.
- LeadFotosGaleria: galería por zona (Antes/Después + notas) que sustituye el
  grid plano de la ficha del panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:22:12 +02:00
Carlos Narro
0a5f8cba2b Añade canales de llamada y WhatsApp al funnel B2C
- Llamada (/llamada): "Llamar ahora" dispara la llamada saliente de Retell;
  "Programar" registra fecha/hora. Tras pedir, ofrece enviar por email el
  enlace al formulario para subir las imágenes.
- WhatsApp (/whatsapp): arranca la conversación vía webhook al flujo externo
  (con el teléfono del lead) y pide confirmación; al confirmar, agradece y
  sigue por WhatsApp.
- actions: pedirLlamada, enviarEnlaceFormularioEmail, iniciarWhatsapp,
  confirmarWhatsapp.

Verificado en navegador: las 3 pantallas y sus transiciones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:20:14 +02:00
Carlos Narro
9b5b0d59a6 Añade chooser de canal y formulario por zonas al funnel B2C
- Paso intermedio /solicitud/[id]: el cliente elige llamada, WhatsApp o
  formulario (crearLead ahora redirige aquí, no a /fotos).
- /formulario: FormularioZonas permite añadir varias zonas, cada una con tipo,
  m², acabado, notas y fotos; /fotos queda como redirect.
- guardarDetallesYFotos: guarda fotos (antes, por zona) y notas (por zona),
  agrega los campos del lead (m² suma, tipo único o 'integral', calidad más
  alta, tasteText concatenado) para el presupuesto orientativo inmediato, y
  señala perfilCompleto al flujo externo.
- Elimina FotosUploader (sustituido por FormularioZonas).

Verificado en navegador: 2 zonas → presupuesto al instante + notas por zona +
evento de perfil en DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:17:11 +02:00
Carlos Narro
f87a3ecd81 Añade copy del funnel multicanal (chooser + llamada + WhatsApp)
COPY-GUIDE §3: paso 2 "Elige cómo seguir" con las 3 tarjetas, pantalla del
canal llamada (ahora/programar + nota de fotos por email/WhatsApp) y pantalla
del canal WhatsApp (contacto directo + confirmación). Texto literal para B1–B4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:11:11 +02:00
Carlos Narro
35ba2f28fe Documenta el EP de ingesta y los webhooks salientes
api-docs/README.md: método/URL, auth Bearer, esquema del cuerpo (items
foto/texto, zona, momento, flags), respuesta y errores (401/404/422), ejemplos
curl de los 5 casos, y los payloads de los 3 webhooks salientes (perfil,
entrega WhatsApp, arranque WhatsApp) con cómo el flujo externo devuelve las
"después" por el mismo EP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:09:59 +02:00
Carlos Narro
ae8984fe13 Añade el EP único de ingesta async del lead
POST /api/leads/:id/ingesta (Bearer FUNNEL_API_KEY): acepta items foto/texto
etiquetados por zona y momento, más flags perfilCompleto y finalizar.
- ingesta-schema.ts: zod del cuerpo (union discriminada foto|texto), exportado
  para test; rechaza llamadas vacías.
- route.ts: auth 401, valida lead (404), inserta fotos (orden continúa el máx)
  y notas, traza fotos_subidas; perfilCompleto→señalarPerfilCompleto,
  finalizar→finalizarYEntregar.
- 10 tests del schema. Verificado por HTTP: 401/200/422/404 y finalizar genera
  el pdf_url y avanza el lead a whatsapp_entregado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:09:16 +02:00
Carlos Narro
195ecf6cc3 Añade webhooks salientes, señal de perfil completo y finalización
- webhooks.ts: postWebhook best-effort + señalarGeneracionPerfil,
  notificarFlujoWhatsapp (entrega) e iniciarConversacionWhatsapp (arranque).
- perfil.ts: señalarPerfilCompleto arma el JSON por zona (notas + fotos
  antes/después) y lo manda al flujo externo; deja traza render_generado.
- finalizar.ts: finalizarYEntregar construye el PDF, persiste pdf_url, envía
  el email (siempre) y la señal de WhatsApp, y avanza el lead a entregado.
- orchestrator: comentario en Paso 7 apuntando a la entrega real en finalizar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:06:38 +02:00
Carlos Narro
ec09267d99 Añade envío de email SMTP del presupuesto y del enlace al formulario
- mailer.ts: transport nodemailer perezoso desde env; enviarPresupuestoEmail
  (adjunta el PDF) y enviarEnlaceFormulario. Best-effort: sin SMTP configurado
  o ante error devuelven false sin lanzar.
- COPY-GUIDE §6.b: copy literal de ambos emails al cliente final.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:05:17 +02:00
Carlos Narro
bcc882e37d Genera el PDF del presupuesto con galería antes/después por zona
- build-presupuesto.ts: construirPresupuestoPdf(leadId) agrupa fotos y notas
  por zona (fallback al tipoReforma del lead), convierte las imágenes con
  resolverImagenPdf y arma el PDF. Carga el lead por id sin scoping (uso
  interno desde el route del panel y desde la finalización pública).
- tenant-queries: getTenantPerfilById(tenantId) sin auth; getTenantPerfil lo
  reutiliza con el tenant de la sesión.
- PresupuestoDoc: prop zonas + sección "Imágenes de tu reforma" (antes/después
  lado a lado + notas por zona).
- route del panel: refactor para reutilizar construirPresupuestoPdf (DRY),
  manteniendo getLead como guardia de auth/404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:04:02 +02:00
Carlos Narro
737496ed89 Añade env de ingesta, SMTP y webhooks + dependencia nodemailer
Variables nuevas (todas opcionales vía zod, sin romper build/demo):
- FUNNEL_API_KEY: clave Bearer del EP de ingesta.
- SMTP_* + EMAIL_FROM: envío de email del presupuesto/enlace.
- PERFIL/WHATSAPP/WHATSAPP_START webhook URLs: señales al flujo externo.
- APP_URL: base para enlaces absolutos.
Helpers emailConfigurado()/perfilWebhookConfigurado()/whatsappWebhookConfigurado()/
whatsappStartConfigurado(). nodemailer como dep directa (stack: Email SMTP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:01:15 +02:00
Carlos Narro
b9dd90f4ef Etiqueta fotos por zona/momento y añade tabla lead_notas
Prepara el modelo de datos para la ingesta multicanal del perfil del lead:
- lead_fotos: columnas momento (foto_momento antes/despues, default antes) y
  zona (tipo_reforma, nullable con fallback al tipoReforma del lead).
- lead_notas: tabla append-only de datos de texto por zona (ej. "suelo
  premium"), con origen (ep|funnel|panel) para auditar quién los aportó.
- Migración 0008 + regenerado db-schema/schema.sql.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:00:08 +02:00
40 changed files with 3812 additions and 357 deletions

View File

@@ -313,6 +313,47 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
- **Botón submit:** *Continuar*
### Paso 2 — Elige cómo seguir (chooser de canal)
- **Etiqueta del paso:** Elige cómo seguir
- **Título del paso:** ¿Cómo prefieres contarnos tu reforma, [Nombre]?
- **Subtitle:** Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render y tu presupuesto.
- **Tarjeta Llamada — título:** Que te llamemos
**Descripción:** Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.
**CTA:** Quiero que me llamen
- **Tarjeta WhatsApp — título:** Por WhatsApp
**Descripción:** Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.
**CTA:** Seguir por WhatsApp
- **Tarjeta Formulario — título:** Rellenar un formulario
**Descripción:** Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.
**CTA:** Rellenar el formulario
### Paso 2 (canal llamada)
- **Título del paso:** Te llamamos cuando quieras
- **Subtitle:** Un asistente de [Reformista] te llama y te hace unas preguntas rápidas sobre tu reforma. Te avisamos antes.
- **Opción A — título:** Llamarme ahora
**Descripción:** Recibes la llamada en menos de 2 minutos.
**Botón:** Llamarme ahora
- **Opción B — título:** Programar la llamada
**Descripción:** Elige el día y la hora que mejor te venga.
**Botón:** Programar llamada
- **Confirmación (ahora):** ✅ Perfecto, [Nombre]. Te llamamos en menos de 2 minutos al **[teléfono]**. Tenlo a mano.
- **Confirmación (programada):** ✅ Hecho. Te llamaremos el **[fecha]** al **[teléfono]**.
- **Nota sobre las fotos:** Para el render necesitamos ver el espacio. Puedes mandarnos las fotos por WhatsApp durante la llamada, o te enviamos un enlace al formulario por email para que las subas cuando quieras.
**Botón:** Enviarme el enlace por email
**Confirmación enlace:** 📧 Te hemos enviado el enlace a **[email]**.
### Paso 2 (canal WhatsApp)
- **Título del paso:** Seguimos por WhatsApp
- **Body (antes de confirmar):** Te escribimos al WhatsApp del **[teléfono]** para seguir por ahí. Si el número es correcto, confírmalo y te escribimos ahora mismo.
- **Botón:** Sí, escríbeme por WhatsApp
- **Tras escribir:** Te acabamos de escribir al **[teléfono]**. ¿Puedes confirmarlo?
**Botón confirmar:** Lo he recibido
- **Agradecimiento:** ✅ ¡Genial, [Nombre]! Seguimos por WhatsApp. Allí te pediremos las fotos y los detalles para preparar tu presupuesto.
### Subida de fotos (paso 2 del wizard)
- **Título del paso:** Ahora una foto de tu espacio actual
@@ -536,6 +577,55 @@ Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel
---
## 6.b Emails al cliente final (funnel B2C)
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.
### Email de entrega del presupuesto (PDF adjunto)
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto.
- **Asunto:** *Tu presupuesto de reforma con [Reformista] ya está listo*
- **Cuerpo:**
> Hola [Nombre],
>
> 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 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]
### 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.
- **Asunto:** *Sube las fotos de tu reforma para [Reformista]*
- **Cuerpo:**
> Hola [Nombre],
>
> 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:
>
> 👉 [Subir mis fotos]([url])
>
> Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu
> presupuesto.
>
> —
> [Reformista]
---
## 7. Microcopy del panel del reformista
| Elemento | Texto |

View File

@@ -7,3 +7,26 @@ DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
RETELL_API_KEY=""
RETELL_AGENT_ID=""
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000
# EP de ingesta del lead (/api/leads/:id/ingesta). Clave compartida que valida al llamante
# externo (Authorization: Bearer ...). Sin ella, el EP responde 401.
FUNNEL_API_KEY=""
# Email (SMTP) para enviar el presupuesto y el enlace al formulario. OPCIONALES: sin SMTP_HOST +
# EMAIL_FROM el envío degrada a no-op (la entrega queda marcada como simulada). Mailhog local:
# SMTP_HOST=localhost SMTP_PORT=1025 (sin user/pass).
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASS=""
EMAIL_FROM="" # remitente, p. ej. "Reformas Ejemplo <no-reply@reformix.es>"
# Webhooks salientes hacia el flujo externo (n8n/generador). OPCIONALES: sin URL la señal no se
# manda. PERFIL = perfil completo (generar renders/agente); WHATSAPP = entrega del PDF;
# WHATSAPP_START = arrancar la conversación de WhatsApp con el lead.
PERFIL_WEBHOOK_URL=""
WHATSAPP_WEBHOOK_URL=""
WHATSAPP_START_WEBHOOK_URL=""
# Base pública de la app, para construir enlaces absolutos (enlace al formulario en el email).
APP_URL="http://localhost:3000"

163
mvp/b2c/api-docs/README.md Normal file
View File

@@ -0,0 +1,163 @@
# API — Ingesta del perfil del lead
Endpoint único y asíncrono para enriquecer el perfil de un lead del funnel B2C. Cada llamada puede
traer **imágenes, imagen+texto o solo texto**, etiquetado por **zona** y, en fotos, por **momento**
(antes/después). Lo usan tanto el formulario web como los flujos externos (agente de llamada, bot de
WhatsApp, generador de renders).
## Endpoint
```
POST /api/leads/:id/ingesta
```
- `:id` = UUID del lead (el que crea el funnel al capturar nombre/teléfono/email).
- **Auth:** header `Authorization: Bearer <FUNNEL_API_KEY>`. Sin él, o con clave incorrecta → `401`.
- **Content-Type:** `application/json`.
## Cuerpo (JSON)
| Campo | Tipo | Notas |
| --- | --- | --- |
| `items` | array | Lista de items `foto` o `texto`. Por defecto `[]`. |
| `perfilCompleto` | boolean | Opcional. `true` señala al flujo externo que genere renders/agente. |
| `finalizar` | boolean | Opcional. `true` construye el PDF y lo entrega (email + señal WhatsApp). |
Debe llegar **al menos un item o un flag**; una llamada totalmente vacía → `422`.
### Item `foto`
| Campo | Tipo | Notas |
| --- | --- | --- |
| `tipo` | `"foto"` | Obligatorio. |
| `imagen` | string | Obligatorio. Data URI (`data:image/...;base64,...`) o URL `http(s)`. |
| `zona` | enum | Opcional: `cocina`,`bano`,`salon`,`comedor`,`integral`,`otro`. Si falta, se asume el tipo de reforma del lead. |
| `momento` | `"antes"`\|`"despues"` | Por defecto `"antes"`. Los renders del flujo externo se mandan como `despues`. |
| `orden` | number | Opcional. Si falta, continúa el máximo actual del lead. |
### Item `texto`
| Campo | Tipo | Notas |
| --- | --- | --- |
| `tipo` | `"texto"` | Obligatorio. |
| `texto` | string | Obligatorio, no vacío. Ej. `"suelo premium"`. |
| `zona` | enum | Opcional (mismo enum que en `foto`). |
## Respuesta
`200`:
```json
{
"ok": true,
"fotos": 1,
"notas": 1,
"perfilSenalado": false,
"finalizado": null
}
```
- `fotos` / `notas`: cuántos items de cada tipo se guardaron.
- `perfilSenalado`: `true` si `perfilCompleto` disparó el webhook y respondió ok.
- `finalizado`: `null` si no se pidió `finalizar`; si sí,
`{ ok, emailEnviado, whatsappSenal }`.
### Códigos de error
| Código | Cuándo |
| --- | --- |
| `401` | Falta `Authorization: Bearer`, o la clave no coincide con `FUNNEL_API_KEY`. |
| `404` | El lead `:id` no existe. |
| `422` | JSON inválido, item mal formado, o llamada vacía (sin items ni flag). |
## Ejemplos (curl)
Sustituye `LEAD`, `KEY` y el host según tu entorno.
```bash
# 1) Solo texto: añade un dato a la zona baño
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"items":[{"tipo":"texto","zona":"bano","texto":"suelo premium"}]}'
# 2) Solo imagen (antes)
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"items":[{"tipo":"foto","zona":"bano","imagen":"https://cdn/ejemplo/bano-antes.jpg"}]}'
# 3) Imagen + texto en la misma llamada
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"items":[
{"tipo":"foto","zona":"cocina","imagen":"data:image/jpeg;base64,..."},
{"tipo":"texto","zona":"cocina","texto":"encimera de cuarzo"}
]}'
# 4) Perfil completo: que el flujo externo genere renders / corra el agente
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"perfilCompleto":true}'
# 5) Devolver renders "después" y finalizar (genera PDF + email + señal WhatsApp)
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"items":[{"tipo":"foto","zona":"cocina","momento":"despues","imagen":"https://cdn/render-cocina.jpg"}],"finalizar":true}'
```
## Webhooks salientes (hacia el flujo externo)
La app **emite** estas señales; el flujo externo (n8n/generador) las recibe y, cuando toca,
devuelve los resultados llamando de nuevo a este mismo EP (las "después" con `finalizar:true`).
Cada webhook es opcional: sin su URL en el entorno, la señal simplemente no se manda.
### `PERFIL_WEBHOOK_URL` — perfil completo
Disparado por `perfilCompleto:true`. Payload:
```json
{
"leadId": "uuid",
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
"zonas": [
{ "zona": "cocina", "notas": ["encimera de cuarzo"],
"fotos": { "antes": ["url", "..."], "despues": [] } }
]
}
```
> El flujo externo genera los renders y los devuelve como items `foto` con `momento:"despues"`
> por `POST /api/leads/:id/ingesta`, y cierra con `finalizar:true`.
### `WHATSAPP_WEBHOOK_URL` — entrega del PDF
Disparado por `finalizar:true`. Payload:
```json
{
"leadId": "uuid",
"telefono": "+34...",
"nombre": "...",
"empresa": "Reformas Ejemplo",
"pdfBase64": "JVBERi0xLj...",
"filename": "presupuesto-nombre.pdf"
}
```
### `WHATSAPP_START_WEBHOOK_URL` — arranque de conversación
Disparado cuando el lead elige continuar por WhatsApp en el funnel. Payload:
```json
{ "leadId": "uuid", "telefono": "+34...", "nombre": "...", "empresa": "Reformas Ejemplo" }
```
## Notas
- **Storage:** las imágenes se guardan tal cual se reciben (data URI o URL) en `lead_fotos.url`;
las notas en `lead_notas`. Ver `mvp/b2c/db-schema/`.
- **Email:** la entrega por email es real vía SMTP (`SMTP_*` + `EMAIL_FROM`). Sin configurar,
`finalizado.emailEnviado` será `false` y el evento queda marcado como simulado.
- Variables de entorno: ver `mvp/b2c/.env.example`.

View File

@@ -1,6 +1,7 @@
CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium');
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');
CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues');
CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido');
CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado');
CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido');
@@ -44,10 +45,21 @@ CREATE TABLE "lead_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"url" text NOT NULL,
"momento" "foto_momento" DEFAULT 'antes' NOT NULL,
"zona" "tipo_reforma",
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "lead_notas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"zona" "tipo_reforma",
"texto" text NOT NULL,
"origen" text DEFAULT 'ep' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "lead_pipeline_eventos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
@@ -195,6 +207,7 @@ ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_f
ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_estado_history" ADD CONSTRAINT "lead_estado_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_fotos" ADD CONSTRAINT "lead_fotos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_pipeline_eventos" ADD CONSTRAINT "lead_pipeline_eventos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "leads" ADD CONSTRAINT "leads_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "precision_history" ADD CONSTRAINT "precision_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,13 @@
CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues');--> statement-breakpoint
CREATE TABLE "lead_notas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"zona" "tipo_reforma",
"texto" text NOT NULL,
"origen" text DEFAULT 'ep' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "lead_fotos" ADD COLUMN "momento" "foto_momento" DEFAULT 'antes' NOT NULL;--> statement-breakpoint
ALTER TABLE "lead_fotos" ADD COLUMN "zona" "tipo_reforma";--> statement-breakpoint
ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1780313493522,
"tag": "0007_pale_chat",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780505942614,
"tag": "0008_sharp_bloodaxe",
"breakpoints": true
}
]
}

View File

@@ -13,6 +13,7 @@
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "16.2.6",
"nodemailer": "^8.0.10",
"postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4",
@@ -24,6 +25,7 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",
@@ -2966,6 +2968,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -7201,6 +7213,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
"integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",

View File

@@ -23,6 +23,7 @@
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "16.2.6",
"nodemailer": "^8.0.10",
"postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4",
@@ -34,6 +35,7 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",

View File

@@ -0,0 +1,102 @@
import { desc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
import { env } from '@/lib/env';
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { finalizarYEntregar } from '@/lib/funnel/finalizar';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function autorizado(req: Request): boolean {
if (!env.FUNNEL_API_KEY) return false;
const auth = req.headers.get('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
return token.length > 0 && token === env.FUNNEL_API_KEY;
}
function json(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
}
// EP único de ingesta async del perfil del lead. Acepta imágenes, imagen+texto o solo texto,
// etiquetado por zona y momento, y dos flags: perfilCompleto (señala al flujo externo que genere
// renders/agente) y finalizar (construye el PDF y lo entrega por email + señal WhatsApp).
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
if (!autorizado(req)) return json({ ok: false, error: 'No autorizado.' }, 401);
const { id } = await params;
let raw: unknown;
try {
raw = await req.json();
} catch {
return json({ ok: false, error: 'JSON inválido.' }, 422);
}
const parsed = ingestaBodySchema.safeParse(raw);
if (!parsed.success) {
return json({ ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' }, 422);
}
const body = parsed.data;
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, id)).limit(1);
if (!lead) return json({ ok: false, error: 'Lead no encontrado.' }, 404);
const fotosItems = body.items.filter((i) => i.tipo === 'foto');
const notasItems = body.items.filter((i) => i.tipo === 'texto');
if (fotosItems.length > 0) {
const [ultimo] = await db
.select({ orden: leadFotos.orden })
.from(leadFotos)
.where(eq(leadFotos.leadId, id))
.orderBy(desc(leadFotos.orden))
.limit(1);
let siguiente = (ultimo?.orden ?? -1) + 1;
const filas: NewLeadFoto[] = fotosItems.map((f) => ({
leadId: id,
url: f.imagen,
momento: f.momento,
zona: f.zona ?? null,
orden: f.orden ?? siguiente++,
}));
await db.insert(leadFotos).values(filas);
}
if (notasItems.length > 0) {
const filas: NewLeadNota[] = notasItems.map((n) => ({
leadId: id,
texto: n.texto,
zona: n.zona ?? null,
origen: 'ep',
}));
await db.insert(leadNotas).values(filas);
}
if (fotosItems.length > 0 || notasItems.length > 0) {
await db.insert(leadPipelineEventos).values({
leadId: id,
stage: 'fotos_subidas',
metadata: { origen: 'ep', fotos: fotosItems.length, notas: notasItems.length },
});
}
const perfilSenalado = body.perfilCompleto ? await señalarPerfilCompleto(id) : false;
const finalizado = body.finalizar ? await finalizarYEntregar(id) : null;
return json(
{
ok: true,
fotos: fotosItems.length,
notas: notasItems.length,
perfilSenalado,
finalizado,
},
200,
);
}

View File

@@ -5,6 +5,7 @@ import { getLead } from '@/db/queries';
import EstadoControl from '@/components/panel/EstadoControl';
import ConceptosEditor from '@/components/panel/ConceptosEditor';
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
import LeadFotosGaleria from '@/components/panel/LeadFotosGaleria';
import {
PIPELINE_LABEL,
PIPELINE_NEXT,
@@ -32,7 +33,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const data = await getLead(id);
if (!data) notFound();
const { lead, fotos, eventos, precision } = data;
const { lead, fotos, notas, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
@@ -275,15 +276,10 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
</Section>
</div>
{/* Fotos subidas */}
{fotos.length > 0 && (
<Section title="Fotos subidas por el cliente">
<div className="flex flex-wrap gap-3">
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={f.id} src={f.url} alt="" className="w-32 h-24 object-cover rounded-lg border border-gray-200" />
))}
</div>
{/* Fotos y notas por zona */}
{(fotos.length > 0 || notas.length > 0) && (
<Section title="Fotos y detalles por zona">
<LeadFotosGaleria fotos={fotos} notas={notas} tipoLead={lead.tipoReforma} />
</Section>
)}

View File

@@ -1,12 +1,6 @@
import { notFound } from 'next/navigation';
import { renderToBuffer } from '@react-pdf/renderer';
import { getLead } from '@/db/queries';
import { getTenantPerfil } from '@/db/tenant-queries';
import { TIPO_LABEL } from '@/lib/funnel';
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
import { construirDescripcionRender, resolverImagenPdf } from '@/lib/pdf/render-info';
import type { BudgetResult } from '@/budget/types';
import type { AbstractedPreferences } from '@/lib/voice/preferences';
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -16,52 +10,18 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// getLead aplica el scoping por tenant del panel: sirve de guardia de auth/404.
const data = await getLead(id);
if (!data) notFound();
const pdf = await construirPresupuestoPdf(id);
if (!pdf) notFound();
const descargar = new URL(req.url).searchParams.get('download') === '1';
const { lead } = data;
const empresa = await getTenantPerfil();
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const [logoSrc, imagenSrc] = await Promise.all([
resolverImagenPdf(empresa.logoUrl, { formato: 'png', maxAncho: 400 }),
resolverImagenPdf(lead.renderUrl, { formato: 'jpeg', maxAncho: 1400 }),
]);
const prefs = lead.preferencesSnapshot as AbstractedPreferences | null;
const render = imagenSrc
? {
imagenSrc,
descripcion: construirDescripcionRender({
calidad: lead.calidadGlobal,
materiales: desglose?.materialesRender ?? [],
estilo: prefs?.estiloRender ?? [],
}),
}
: null;
const buffer = await renderToBuffer(
PresupuestoDoc({
empresa,
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
reforma: {
tipoLabel: lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma',
fecha: lead.createdAt,
},
desglose,
logoSrc,
render,
})
);
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return new Response(new Uint8Array(buffer), {
return new Response(new Uint8Array(pdf.buffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="presupuesto-${slug || lead.id}.pdf"`,
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="${pdf.filename}"`,
'Cache-Control': 'no-store',
},
});

View File

@@ -0,0 +1,39 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { guardarDetallesYFotos } from '../../actions';
import FormularioZonas from '@/components/funnel/FormularioZonas';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
export default async function FormularioPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, tenant } = data;
return (
<>
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
<div className="container py-10 max-w-2xl flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Cuéntanos tu reforma
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Añade cada zona que quieras reformar con sus fotos y detalles. Con eso preparamos tu
render y un presupuesto orientativo en menos de un minuto.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<FormularioZonas action={guardarDetallesYFotos.bind(null, id)} />
</div>
</div>
</>
);
}

View File

@@ -1,39 +1,10 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { guardarDetallesYFotos } from '../../actions';
import FotosUploader from '@/components/funnel/FotosUploader';
import TenantBrand from '@/components/funnel/TenantBrand';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) {
// La subida de fotos vive ahora en /formulario (formulario por zonas). Mantenemos /fotos como
// redirect por compatibilidad con enlaces antiguos.
export default async function FotosRedirect({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, tenant } = data;
return (
<>
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
<div className="container py-10 max-w-2xl flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Paso 2 de 2
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Sube unas fotos del espacio y dinos qué tienes en mente. Con eso preparamos tu render y un
presupuesto orientativo en menos de un minuto.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<FotosUploader action={guardarDetallesYFotos.bind(null, id)} />
</div>
</div>
</>
);
redirect(`/solicitud/${id}/formulario`);
}

View File

@@ -0,0 +1,38 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import CanalLlamada from '@/components/funnel/CanalLlamada';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
export default async function LlamadaPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, tenant } = data;
return (
<>
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
<div className="container py-10 max-w-2xl flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Te llamamos
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Te llamamos cuando quieras, {lead.nombre.split(' ')[0]}
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Un asistente de {tenant?.nombreEmpresa ?? 'la empresa'} te llama y te hace unas preguntas
rápidas sobre tu reforma. Te avisamos antes.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<CanalLlamada leadId={id} telefono={lead.telefono} email={lead.email} />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,82 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
const CANALES = [
{
slug: 'llamada',
icon: '📞',
titulo: 'Que te llamemos',
descripcion:
'Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.',
cta: 'Quiero que me llamen',
},
{
slug: 'whatsapp',
icon: '💬',
titulo: 'Por WhatsApp',
descripcion:
'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
cta: 'Seguir por WhatsApp',
},
{
slug: 'formulario',
icon: '📝',
titulo: 'Rellenar un formulario',
descripcion:
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
cta: 'Rellenar el formulario',
},
] as const;
export default async function ChooserPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, tenant } = data;
return (
<>
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
<div className="container py-10 max-w-2xl flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Elige cómo seguir
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}?
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render
y tu presupuesto.
</p>
</div>
<div className="flex flex-col gap-3">
{CANALES.map((c) => (
<Link
key={c.slug}
href={`/solicitud/${id}/${c.slug}`}
className="group bg-white border border-gray-200 rounded-xl p-5 shadow-sm flex items-center gap-4 transition-all hover:border-black hover:shadow-md"
>
<span className="text-3xl shrink-0" aria-hidden="true">
{c.icon}
</span>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-base font-bold text-black">{c.titulo}</span>
<span className="text-sm text-gray-500 leading-snug">{c.descripcion}</span>
<span className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] mt-1">
{c.cta}
</span>
</div>
</Link>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,32 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import CanalWhatsapp from '@/components/funnel/CanalWhatsapp';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
export default async function WhatsappPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, tenant } = data;
return (
<>
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
<div className="container py-10 max-w-2xl flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Por WhatsApp
</span>
<h1 className="text-2xl font-black tracking-tight text-black">Seguimos por WhatsApp</h1>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<CanalWhatsapp leadId={id} nombre={lead.nombre} telefono={lead.telefono} />
</div>
</div>
</>
);
}

View File

@@ -5,11 +5,19 @@ import { and, eq } from 'drizzle-orm';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { leads, leadFotos, leadPipelineEventos } from '@/db/schema';
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
import { getTenantBySlug } from '@/lib/funnel/public-queries';
import { getTenantPerfilById } from '@/db/tenant-queries';
import { procesarLead } from '@/lib/funnel/orchestrator';
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 { env } from '@/lib/env';
const MAX_FOTOS = 4;
const MAX_ZONAS = 6;
const MAX_FOTOS_ZONA = 6;
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
const crearLeadSchema = z.object({
@@ -79,27 +87,57 @@ async function fileToDataUri(file: File): Promise<string | null> {
return `data:${file.type};base64,${buffer.toString('base64')}`;
}
// Paso 3 del funnel: el cliente sube fotos y confirma los datos clave de la reforma.
// Guardamos las fotos como data URI (no hay storage externo en esta fase) y disparamos
// el orquestador que simula la llamada/render y calcula el presupuesto real.
const CALIDAD_RANK: Record<(typeof CALIDADES)[number], number> = { basica: 0, media: 1, premium: 2 };
type ZonaParseada = {
tipo: (typeof TIPOS)[number];
m2: number | null;
calidad: (typeof CALIDADES)[number];
notas: string | null;
fotos: string[]; // data URIs
};
// Lee las zonas del FormData (campos zona-<i>-tipo / -m2 / -calidad / -notas / -fotos).
async function parsearZonas(formData: FormData): Promise<ZonaParseada[]> {
const count = Math.min(Number(formData.get('zonasCount')) || 0, MAX_ZONAS);
const zonas: ZonaParseada[] = [];
for (let i = 0; i < count; i++) {
const tipoRaw = String(formData.get(`zona-${i}-tipo`) ?? '');
const calidadRaw = String(formData.get(`zona-${i}-calidad`) ?? '');
const m2Raw = Number(formData.get(`zona-${i}-m2`));
const tipo = (TIPOS as readonly string[]).includes(tipoRaw)
? (tipoRaw as (typeof TIPOS)[number])
: 'otro';
const calidad = (CALIDADES as readonly string[]).includes(calidadRaw)
? (calidadRaw as (typeof CALIDADES)[number])
: 'media';
const m2 = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
const notas = String(formData.get(`zona-${i}-notas`) ?? '').trim() || null;
const archivos = formData
.getAll(`zona-${i}-fotos`)
.filter((f): f is File => f instanceof File)
.slice(0, MAX_FOTOS_ZONA);
const fotos: string[] = [];
for (const file of archivos) {
const uri = await fileToDataUri(file);
if (uri) fotos.push(uri);
}
zonas.push({ tipo, m2, calidad, notas, fotos });
}
return zonas;
}
// Paso 2 (canal formulario): el cliente describe la reforma zona por zona y sube fotos.
// Guardamos fotos (momento 'antes', etiquetadas por zona) y notas como data en lead_notas;
// agregamos los campos del lead para calcular el presupuesto orientativo al instante con el motor
// actual, y señalamos "perfil completo" al flujo externo para que genere los renders "después".
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) throw new Error('Solicitud no encontrada.');
const tenantId = lead.tenantId;
const tipoRaw = String(formData.get('tipoReforma') ?? '');
const calidadRaw = String(formData.get('calidad') ?? '');
const m2Raw = Number(formData.get('m2'));
const provincia = String(formData.get('provincia') ?? '').trim() || null;
const tipoReforma = (TIPOS as readonly string[]).includes(tipoRaw)
? (tipoRaw as (typeof TIPOS)[number])
: 'otro';
const calidadGlobal = (CALIDADES as readonly string[]).includes(calidadRaw)
? (calidadRaw as (typeof CALIDADES)[number])
: 'media';
const m2Suelo = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
const urgenciaRaw = String(formData.get('urgencia') ?? '');
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
? (urgenciaRaw as 'alta' | 'media' | 'baja')
@@ -108,20 +146,40 @@ 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 tasteText = String(formData.get('tasteText') ?? '').trim() || null;
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
const dataUris: string[] = [];
for (const file of archivos.slice(0, MAX_FOTOS)) {
const uri = await fileToDataUri(file);
if (uri) dataUris.push(uri);
let zonas = await parsearZonas(formData);
if (zonas.length === 0) {
zonas = [{ tipo: 'otro', m2: null, calidad: 'media', notas: null, fotos: [] }];
}
if (dataUris.length > 0) {
await db.insert(leadFotos).values(
dataUris.map((url, orden) => ({ leadId, url, orden }))
// Inserta fotos (antes, por zona) y notas (por zona) en la estructura del lead.
const fotoRows: NewLeadFoto[] = [];
const notaRows: NewLeadNota[] = [];
let orden = 0;
for (const z of zonas) {
for (const url of z.fotos) {
fotoRows.push({ leadId, url, momento: 'antes', zona: z.tipo, orden: orden++ });
}
if (z.notas) notaRows.push({ leadId, texto: z.notas, zona: z.tipo, origen: 'funnel' });
}
if (fotoRows.length > 0) await db.insert(leadFotos).values(fotoRows);
if (notaRows.length > 0) await db.insert(leadNotas).values(notaRows);
// Agregado para el motor de presupuesto (multi-zona "de verdad" = F1.5): m² suma, tipo único
// o 'integral' si hay varias zonas, calidad la más alta, y tasteText con las notas concatenadas.
const tiposUnicos = Array.from(new Set(zonas.map((z) => z.tipo)));
const tipoReforma = tiposUnicos.length === 1 ? tiposUnicos[0] : 'integral';
const m2Total = zonas.reduce((s, z) => s + (z.m2 ?? 0), 0);
const m2Suelo = m2Total > 0 ? m2Total : null;
const calidadGlobal = zonas.reduce<(typeof CALIDADES)[number]>(
(best, z) => (CALIDAD_RANK[z.calidad] > CALIDAD_RANK[best] ? z.calidad : best),
'basica',
);
}
const tasteText =
zonas
.filter((z) => z.notas)
.map((z) => `${z.tipo}: ${z.notas}`)
.join('\n') || null;
await db
.update(leads)
@@ -142,12 +200,90 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'fotos_subidas',
metadata: { fotos: dataUris.length },
metadata: { fotos: fotoRows.length, notas: notaRows.length, zonas: zonas.length },
});
// Dispara el resto del pipeline (llamada simulada → render → presupuesto real → WhatsApp).
// Presupuesto orientativo inmediato (motor actual). La rama de WhatsApp queda simulada.
await procesarLead(leadId);
// Señala al flujo externo que el perfil está listo para generar los renders "después".
await señalarPerfilCompleto(leadId);
revalidatePath('/panel');
redirect(`/solicitud/${leadId}/estado`);
}
// Canal llamada: el cliente pide que le llamen ahora o programa la llamada. "Ahora" dispara la
// llamada saliente de Retell; "programar" registra la fecha y la señala (el dialing en hora lo
// hace el flujo externo, la app no monta cron). Best-effort.
export async function pedirLlamada(
leadId: string,
cuando: 'ahora' | string,
): Promise<{ ok: boolean; programada?: string }> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return { ok: false };
const tenant = await getTenantPerfilById(lead.tenantId);
if (cuando === 'ahora') {
const llamada = await iniciarLlamadaSaliente({
telefono: lead.telefono,
variables: construirVariablesLlamada({ nombreEmpresa: tenant.nombreEmpresa }, lead),
});
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'prellamada_enviada',
metadata: { via: 'llamada', cuando: 'ahora', real: Boolean(llamada), simulado: !llamada },
});
return { ok: true };
}
const fecha = new Date(cuando);
const programadaAt = Number.isNaN(fecha.getTime()) ? null : fecha.toISOString();
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'prellamada_enviada',
metadata: { via: 'llamada', cuando: 'programada', programadaAt },
});
return { ok: true, programada: programadaAt ?? undefined };
}
// Canal llamada: envía al cliente un email con el enlace a su formulario para subir las imágenes.
export async function enviarEnlaceFormularioEmail(leadId: string): Promise<boolean> {
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 url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
return enviarEnlaceFormulario({
to: lead.email,
nombre: lead.nombre,
empresa: tenant.nombreEmpresa,
url,
});
}
// Canal WhatsApp: arranca la conversación con el lead a través del flujo externo (que manda el
// primer mensaje a su teléfono) y deja traza. El cliente confirma luego en la UI.
export async function iniciarWhatsapp(leadId: string): Promise<{ ok: boolean; telefono: string }> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return { ok: false, telefono: '' };
const tenant = await getTenantPerfilById(lead.tenantId);
const ok = await iniciarConversacionWhatsapp({
leadId,
telefono: lead.telefono,
nombre: lead.nombre,
empresa: tenant.nombreEmpresa,
});
return { ok, telefono: lead.telefono };
}
// Canal WhatsApp: el cliente confirma que ha recibido el mensaje; seguimos por WhatsApp.
export async function confirmarWhatsapp(leadId: string): Promise<{ ok: boolean }> {
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return { ok: false };
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'prellamada_enviada',
metadata: { via: 'whatsapp', confirmado: true },
});
return { ok: true };
}

View File

@@ -111,7 +111,7 @@ export default function ContactForm({ slug }: { slug: string }) {
setSubmitError(result.error);
return;
}
router.push(`/solicitud/${result.leadId}/fotos`);
router.push(`/solicitud/${result.leadId}`);
};
const handleReset = () => {

View File

@@ -91,7 +91,7 @@ function LeadForm({ slug }: { slug: string }) {
setSubmitError(result.error);
return;
}
router.push(`/solicitud/${result.leadId}/fotos`);
router.push(`/solicitud/${result.leadId}`);
};
const handleReset = () => {

View File

@@ -0,0 +1,124 @@
'use client';
import { useState, useTransition } from 'react';
import { pedirLlamada, enviarEnlaceFormularioEmail } from '@/app/solicitud/actions';
export default function CanalLlamada({
leadId,
telefono,
email,
}: {
leadId: string;
telefono: string;
email: string;
}) {
const [pending, startTransition] = useTransition();
const [confirmacion, setConfirmacion] = useState<string | null>(null);
const [mostrarProgramar, setMostrarProgramar] = useState(false);
const [cuando, setCuando] = useState('');
const [enlaceEnviado, setEnlaceEnviado] = useState(false);
const llamarAhora = () =>
startTransition(async () => {
await pedirLlamada(leadId, 'ahora');
setConfirmacion(`Te llamamos en menos de 2 minutos al ${telefono}. Tenlo a mano.`);
});
const programar = () =>
startTransition(async () => {
if (!cuando) return;
const r = await pedirLlamada(leadId, cuando);
const fecha = r.programada
? new Date(r.programada).toLocaleString('es-ES', {
day: '2-digit',
month: 'long',
hour: '2-digit',
minute: '2-digit',
})
: '';
setConfirmacion(`Hecho. Te llamaremos el ${fecha} al ${telefono}.`);
});
const enviarEnlace = () =>
startTransition(async () => {
await enviarEnlaceFormularioEmail(leadId);
setEnlaceEnviado(true);
});
if (confirmacion) {
return (
<div className="flex flex-col gap-4">
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-4 py-3">
{confirmacion}
</div>
<div className="border-t border-gray-100 pt-4 flex flex-col gap-3">
<p className="text-sm text-gray-500 leading-relaxed">
Para el render necesitamos ver el espacio. Puedes mandarnos las fotos por WhatsApp
durante la llamada, o te enviamos un enlace al formulario por email para que las subas
cuando quieras.
</p>
{enlaceEnviado ? (
<div className="text-sm text-gray-600">📧 Te hemos enviado el enlace a {email}.</div>
) : (
<button
type="button"
onClick={enviarEnlace}
disabled={pending}
className="btn btn-secondary self-start disabled:opacity-60"
>
Enviarme el enlace por email
</button>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="border border-gray-200 rounded-xl p-5 flex flex-col gap-2">
<span className="text-base font-bold text-black">Llamarme ahora</span>
<span className="text-sm text-gray-500">Recibes la llamada en menos de 2 minutos.</span>
<button
type="button"
onClick={llamarAhora}
disabled={pending}
className="btn btn-primary self-start mt-2 disabled:opacity-60"
>
{pending ? 'Pidiendo…' : 'Llamarme ahora'}
</button>
</div>
<div className="border border-gray-200 rounded-xl p-5 flex flex-col gap-2">
<span className="text-base font-bold text-black">Programar la llamada</span>
<span className="text-sm text-gray-500">Elige el día y la hora que mejor te venga.</span>
{mostrarProgramar ? (
<div className="flex flex-col gap-3 mt-2">
<input
type="datetime-local"
value={cuando}
onChange={(e) => setCuando(e.target.value)}
className="w-full px-4 py-3 text-base text-dark bg-white border-[1.5px] border-gray-200 rounded-lg outline-none focus:border-black"
/>
<button
type="button"
onClick={programar}
disabled={pending || !cuando}
className="btn btn-primary self-start disabled:opacity-60"
>
{pending ? 'Programando…' : 'Confirmar la cita'}
</button>
</div>
) : (
<button
type="button"
onClick={() => setMostrarProgramar(true)}
className="btn btn-secondary self-start mt-2"
>
Programar llamada
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState, useTransition } from 'react';
import { iniciarWhatsapp, confirmarWhatsapp } from '@/app/solicitud/actions';
type Fase = 'idle' | 'escrito' | 'confirmado';
export default function CanalWhatsapp({
leadId,
nombre,
telefono,
}: {
leadId: string;
nombre: string;
telefono: string;
}) {
const [pending, startTransition] = useTransition();
const [fase, setFase] = useState<Fase>('idle');
const escribir = () =>
startTransition(async () => {
await iniciarWhatsapp(leadId);
setFase('escrito');
});
const confirmar = () =>
startTransition(async () => {
await confirmarWhatsapp(leadId);
setFase('confirmado');
});
if (fase === 'confirmado') {
return (
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-4 py-3">
¡Genial, {nombre.split(' ')[0]}! Seguimos por WhatsApp. Allí te pediremos las fotos y los
detalles para preparar tu presupuesto.
</div>
);
}
if (fase === 'escrito') {
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 leading-relaxed">
Te acabamos de escribir al <strong className="text-black">{telefono}</strong>. ¿Puedes
confirmarlo?
</p>
<button
type="button"
onClick={confirmar}
disabled={pending}
className="btn btn-primary self-start disabled:opacity-60"
>
{pending ? 'Confirmando…' : 'Lo he recibido'}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 leading-relaxed">
Te escribimos al WhatsApp del <strong className="text-black">{telefono}</strong> para seguir
por ahí. Si el número es correcto, confírmalo y te escribimos ahora mismo.
</p>
<button
type="button"
onClick={escribir}
disabled={pending}
className="btn btn-primary self-start disabled:opacity-60"
>
{pending ? 'Escribiendo…' : 'Sí, escríbeme por WhatsApp'}
</button>
</div>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import { useState } from 'react';
import { useFormStatus } from 'react-dom';
import { TIPO_LABEL } from '@/lib/funnel';
const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
const CALIDADES = [
{ value: 'basica', label: 'Básica' },
{ value: 'media', label: 'Media' },
{ value: 'premium', label: 'Premium' },
] as const;
const URGENCIAS = [
{ value: 'alta', label: 'Cuanto antes' },
{ value: 'media', label: 'En unos meses' },
{ value: 'baja', label: 'Sin prisa' },
] as const;
const MAX_ZONAS = 6;
const MAX_FOTOS_ZONA = 6;
const inputClass =
'w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] border-gray-200 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)]';
type Zona = { key: number; tipo: string };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
className="btn btn-primary btn-lg w-full justify-center mt-1 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
disabled={pending}
aria-busy={pending}
>
{pending ? (
<>
<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"
/>
Generando tu presupuesto...
</>
) : (
'Pedir mi presupuesto'
)}
</button>
);
}
function ZonaCard({
index,
zona,
onTipoChange,
onRemove,
removable,
}: {
index: number;
zona: Zona;
onTipoChange: (tipo: string) => void;
onRemove: () => void;
removable: boolean;
}) {
const [previews, setPreviews] = useState<string[]>([]);
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []).slice(0, MAX_FOTOS_ZONA);
previews.forEach((url) => URL.revokeObjectURL(url));
setPreviews(files.map((f) => URL.createObjectURL(f)));
};
return (
<div className="border border-gray-200 rounded-xl p-5 flex flex-col gap-4 bg-gray-50/50">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-black">Zona {index + 1}</span>
{removable && (
<button
type="button"
onClick={onRemove}
className="text-xs text-gray-400 hover:text-red-600 font-medium"
>
Quitar
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor={`zona-${index}-tipo`} className="text-sm font-semibold text-dark">
¿Qué zona es?
</label>
<select
id={`zona-${index}-tipo`}
name={`zona-${index}-tipo`}
value={zona.tipo}
onChange={(e) => onTipoChange(e.target.value)}
className={inputClass}
>
{TIPOS.map((t) => (
<option key={t} value={t}>
{TIPO_LABEL[t]}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`zona-${index}-m2`} className="text-sm font-semibold text-dark">
Metros cuadrados <span className="text-gray-400 font-normal">(aprox.)</span>
</label>
<input
id={`zona-${index}-m2`}
name={`zona-${index}-m2`}
type="number"
min="1"
step="1"
inputMode="numeric"
placeholder="12"
className={inputClass}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`zona-${index}-calidad`} className="text-sm font-semibold text-dark">
Nivel de acabado
</label>
<select
id={`zona-${index}-calidad`}
name={`zona-${index}-calidad`}
defaultValue="media"
className={inputClass}
>
{CALIDADES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`zona-${index}-notas`} className="text-sm font-semibold text-dark">
Detalles de esta zona <span className="text-gray-400 font-normal">(opcional)</span>
</label>
<textarea
id={`zona-${index}-notas`}
name={`zona-${index}-notas`}
rows={2}
placeholder="Materiales, estilo, caprichos… (ej. suelo porcelánico, encimera de cuarzo, ducha de obra)."
className={inputClass}
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`zona-${index}-fotos`} className="text-sm font-semibold text-dark">
Fotos de la zona{' '}
<span className="text-gray-400 font-normal">(hasta {MAX_FOTOS_ZONA})</span>
</label>
<input
id={`zona-${index}-fotos`}
name={`zona-${index}-fotos`}
type="file"
accept="image/*"
multiple
onChange={handleFiles}
className="block w-full text-sm text-gray-600 file:mr-4 file:py-2.5 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-black file:text-white hover:file:bg-gray-800 file:cursor-pointer cursor-pointer"
/>
{previews.length > 0 && (
<div className="flex flex-wrap gap-3 mt-2">
{previews.map((url, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={url}
alt=""
className="w-20 h-20 object-cover rounded-lg border border-gray-200"
/>
))}
</div>
)}
</div>
</div>
);
}
export default function FormularioZonas({
action,
}: {
action: (formData: FormData) => void | Promise<void>;
}) {
const [zonas, setZonas] = useState<Zona[]>([{ key: 0, tipo: 'cocina' }]);
const [nextKey, setNextKey] = useState(1);
const addZona = () => {
if (zonas.length >= MAX_ZONAS) return;
setZonas((z) => [...z, { key: nextKey, tipo: 'bano' }]);
setNextKey((k) => k + 1);
};
return (
<form action={action} className="flex flex-col gap-6">
<input type="hidden" name="zonasCount" value={zonas.length} />
<div className="flex flex-col gap-4">
{zonas.map((z, i) => (
<ZonaCard
key={z.key}
index={i}
zona={z}
removable={zonas.length > 1}
onTipoChange={(tipo) =>
setZonas((prev) => prev.map((p) => (p.key === z.key ? { ...p, tipo } : p)))
}
onRemove={() => setZonas((prev) => prev.filter((p) => p.key !== z.key))}
/>
))}
</div>
{zonas.length < MAX_ZONAS && (
<button
type="button"
onClick={addZona}
className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] self-start hover:underline"
>
+ Añadir otra zona
</button>
)}
<div className="border-t border-gray-100 pt-5 flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="provincia" className="text-sm font-semibold text-dark">
Provincia
</label>
<input
id="provincia"
name="provincia"
type="text"
placeholder="Madrid"
autoComplete="address-level1"
className={inputClass}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="urgencia" className="text-sm font-semibold text-dark">
¿Para cuándo?
</label>
<select id="urgencia" name="urgencia" defaultValue="media" className={inputClass}>
{URGENCIAS.map((u) => (
<option key={u.value} value={u.value}>
{u.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="presupuestoTarget" className="text-sm font-semibold text-dark">
Presupuesto objetivo <span className="text-gray-400 font-normal">(opcional, )</span>
</label>
<input
id="presupuestoTarget"
name="presupuestoTarget"
type="number"
min="0"
step="100"
inputMode="numeric"
placeholder="8000"
className={inputClass}
/>
</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>
<SubmitButton />
<p className="text-xs text-gray-400 text-center">
Calculamos un presupuesto orientativo con tus datos. Sin compromiso.
</p>
</form>
);
}

View File

@@ -1,209 +0,0 @@
'use client';
import { useState } from 'react';
import { useFormStatus } from 'react-dom';
import { TIPO_LABEL } from '@/lib/funnel';
const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
const CALIDADES = [
{ value: 'basica', label: 'Básica' },
{ value: 'media', label: 'Media' },
{ value: 'premium', label: 'Premium' },
] as const;
const URGENCIAS = [
{ value: 'alta', label: 'Cuanto antes' },
{ value: 'media', label: 'En unos meses' },
{ value: 'baja', label: 'Sin prisa' },
] as const;
const MAX_FOTOS = 4;
const inputClass =
'w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] border-gray-200 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)]';
function SubmitButton({ disabled }: { disabled: boolean }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
className="btn btn-primary btn-lg w-full justify-center mt-1 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
disabled={pending || disabled}
aria-busy={pending}
>
{pending ? (
<>
<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"
/>
Generando tu presupuesto...
</>
) : (
'Generar mi presupuesto'
)}
</button>
);
}
export default function FotosUploader({
action,
}: {
action: (formData: FormData) => void | Promise<void>;
}) {
const [previews, setPreviews] = useState<string[]>([]);
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []).slice(0, MAX_FOTOS);
previews.forEach((url) => URL.revokeObjectURL(url));
setPreviews(files.map((f) => URL.createObjectURL(f)));
};
return (
<form action={action} className="flex flex-col gap-5">
{/* Fotos */}
<div className="flex flex-col gap-2">
<label htmlFor="fotos" className="text-sm font-semibold text-dark">
Sube fotos del espacio <span className="text-gray-400 font-normal">(hasta {MAX_FOTOS})</span>
</label>
<input
id="fotos"
name="fotos"
type="file"
accept="image/*"
multiple
onChange={handleFiles}
className="block w-full text-sm text-gray-600 file:mr-4 file:py-2.5 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-black file:text-white hover:file:bg-gray-800 file:cursor-pointer cursor-pointer"
/>
{previews.length > 0 && (
<div className="flex flex-wrap gap-3 mt-2">
{previews.map((url, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={url}
alt=""
className="w-20 h-20 object-cover rounded-lg border border-gray-200"
/>
))}
</div>
)}
</div>
{/* Tipo de reforma */}
<div className="flex flex-col gap-2">
<label htmlFor="tipoReforma" className="text-sm font-semibold text-dark">
¿Qué quieres reformar?
</label>
<select id="tipoReforma" name="tipoReforma" defaultValue="cocina" className={inputClass}>
{TIPOS.map((t) => (
<option key={t} value={t}>
{TIPO_LABEL[t]}
</option>
))}
</select>
</div>
{/* m2 + calidad */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="m2" className="text-sm font-semibold text-dark">
Metros cuadrados <span className="text-gray-400 font-normal">(aprox.)</span>
</label>
<input
id="m2"
name="m2"
type="number"
min="1"
step="1"
inputMode="numeric"
placeholder="12"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="calidad" className="text-sm font-semibold text-dark">
Nivel de acabado
</label>
<select id="calidad" name="calidad" defaultValue="media" className={inputClass}>
{CALIDADES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
</div>
{/* Provincia */}
<div className="flex flex-col gap-2">
<label htmlFor="provincia" className="text-sm font-semibold text-dark">
Provincia
</label>
<input
id="provincia"
name="provincia"
type="text"
placeholder="Madrid"
autoComplete="address-level1"
className={inputClass}
/>
</div>
{/* Urgencia + presupuesto objetivo */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="urgencia" className="text-sm font-semibold text-dark">
¿Para cuándo?
</label>
<select id="urgencia" name="urgencia" defaultValue="media" className={inputClass}>
{URGENCIAS.map((u) => (
<option key={u.value} value={u.value}>
{u.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="presupuestoTarget" className="text-sm font-semibold text-dark">
Presupuesto objetivo <span className="text-gray-400 font-normal">(opcional, )</span>
</label>
<input
id="presupuestoTarget"
name="presupuestoTarget"
type="number"
min="0"
step="100"
inputMode="numeric"
placeholder="8000"
className={inputClass}
/>
</div>
</div>
{/* Cambios estructurales */}
<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>
{/* Bloque abierto de gustos */}
<div className="flex flex-col gap-2">
<label htmlFor="tasteText" className="text-sm font-semibold text-dark">
Cuéntanos cómo lo imaginas
</label>
<textarea
id="tasteText"
name="tasteText"
rows={4}
placeholder="Estilo, colores, materiales que te gusten… y cualquier capricho que no quieras que falte (una isla, ducha de obra, encimera de cuarzo…)."
className={inputClass}
/>
</div>
<SubmitButton disabled={false} />
<p className="text-xs text-gray-400 text-center">
Calculamos un presupuesto orientativo con tus datos. Sin compromiso.
</p>
</form>
);
}

View File

@@ -0,0 +1,56 @@
import type { Lead, LeadFoto, LeadNota } from '@/db/schema';
import { TIPO_LABEL } from '@/lib/funnel';
import { agruparPorZona } from '@/lib/funnel/fotos';
function Fila({ titulo, fotos }: { titulo: string; fotos: LeadFoto[] }) {
if (fotos.length === 0) return null;
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs uppercase tracking-wide font-semibold text-gray-400">{titulo}</span>
<div className="flex flex-wrap gap-2">
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={f.id}
src={f.url}
alt=""
className="w-28 h-20 object-cover rounded-lg border border-gray-200"
/>
))}
</div>
</div>
);
}
// Galería de la ficha: fotos antes/después y notas agrupadas por zona.
export default function LeadFotosGaleria({
fotos,
notas,
tipoLead,
}: {
fotos: LeadFoto[];
notas: LeadNota[];
tipoLead: Lead['tipoReforma'];
}) {
const grupos = agruparPorZona(fotos, notas, tipoLead ?? 'otro');
if (grupos.length === 0) return null;
return (
<div className="flex flex-col gap-5">
{grupos.map((g) => (
<div key={g.zona} className="flex flex-col gap-3 border border-gray-200 rounded-lg p-4">
<span className="text-sm font-bold text-black">{TIPO_LABEL[g.zona]}</span>
<Fila titulo="Antes" fotos={g.antes} />
<Fila titulo="Después" fotos={g.despues} />
{g.notas.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-gray-600">
{g.notas.map((n) => (
<li key={n.id}> {n.texto}</li>
))}
</ul>
)}
</div>
))}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { db } from './index';
import {
leads,
leadFotos,
leadNotas,
leadEstadoHistory,
leadPipelineEventos,
precisionHistory,
@@ -31,8 +32,9 @@ export async function getLead(id: string) {
if (!lead) return null;
const [fotos, eventos, historial, precision] = await Promise.all([
const [fotos, notas, eventos, historial, precision] = await Promise.all([
db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)),
db.select().from(leadNotas).where(eq(leadNotas.leadId, id)).orderBy(asc(leadNotas.createdAt)),
db
.select()
.from(leadPipelineEventos)
@@ -46,7 +48,7 @@ export async function getLead(id: string) {
db.select().from(precisionHistory).where(eq(precisionHistory.leadId, id)),
]);
return { lead, fotos, eventos, historial, precision: precision[0] ?? null };
return { lead, fotos, notas, eventos, historial, precision: precision[0] ?? null };
}
export async function getResumen() {

View File

@@ -47,6 +47,9 @@ export const tipoReforma = pgEnum('tipo_reforma', [
export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']);
// Momento de una foto del lead: el estado antes de la reforma o el render del después.
export const fotoMomento = pgEnum('foto_momento', ['antes', 'despues']);
export const urgencia = pgEnum('urgencia', ['alta', 'media', 'baja']);
export const categoriaMaterial = pgEnum('categoria_material', [
@@ -214,17 +217,34 @@ export const leads = pgTable(
]
);
// Fotos subidas por el cliente (paso 3, 2-4 fotos)
// Fotos del lead, etiquetadas por zona y momento. Las "antes" las sube el cliente (funnel o EP);
// las "despues" (renders) las devuelve el flujo de generación externo por el mismo EP de ingesta.
// zona es nullable por compatibilidad con filas antiguas (fallback al tipoReforma del lead).
export const leadFotos = pgTable('lead_fotos', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
url: text('url').notNull(),
momento: fotoMomento('momento').notNull().default('antes'),
zona: tipoReforma('zona'),
orden: integer('orden').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// Datos de texto que enriquecen el perfil del lead por zona (ej. "Baño: suelo premium").
// Append-only: cada llamada al EP de ingesta puede añadir notas que el agente externo homologará.
export const leadNotas = pgTable('lead_notas', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
zona: tipoReforma('zona'),
texto: text('texto').notNull(),
origen: text('origen').notNull().default('ep'), // ep | funnel | panel
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// Opiniones del cliente final, recogidas en el funnel de review (/opinion/[id]).
// El reformista las solicita desde el panel y aprueba antes de que salgan en su landing.
export const testimonios = pgTable(
@@ -350,6 +370,9 @@ export type Tenant = typeof tenants.$inferSelect;
export type Lead = typeof leads.$inferSelect;
export type NewLead = typeof leads.$inferInsert;
export type LeadFoto = typeof leadFotos.$inferSelect;
export type NewLeadFoto = typeof leadFotos.$inferInsert;
export type LeadNota = typeof leadNotas.$inferSelect;
export type NewLeadNota = typeof leadNotas.$inferInsert;
export type Testimonio = typeof testimonios.$inferSelect;
export type NewTestimonio = typeof testimonios.$inferInsert;
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;

View File

@@ -23,8 +23,29 @@ export type TenantPerfil = {
themeColor: string | null;
};
export async function getTenantPerfil(): Promise<TenantPerfil> {
const tenantId = await getTenantId();
const TENANT_PERFIL_FALLBACK: TenantPerfil = {
nombreEmpresa: 'Reformix',
slug: '',
logoUrl: null,
provincia: null,
cif: null,
direccion: null,
telefono: null,
email: null,
web: null,
seoTitle: null,
seoDescription: null,
aboutEnabled: false,
aboutFotoUrl: null,
aboutTexto: null,
aniosExperiencia: null,
themePreset: 'pizarra',
themeColor: null,
};
// Perfil del reformista por id, sin depender del contexto de auth. Lo usa el builder de PDF
// y la finalización, que corren desde el EP público (sin sesión).
export async function getTenantPerfilById(tenantId: string): Promise<TenantPerfil> {
const [row] = await db
.select({
nombreEmpresa: tenants.nombreEmpresa,
@@ -49,27 +70,12 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
.where(eq(tenants.id, tenantId))
.limit(1);
return (
row ?? {
nombreEmpresa: 'Reformix',
slug: '',
logoUrl: null,
provincia: null,
cif: null,
direccion: null,
telefono: null,
email: null,
web: null,
seoTitle: null,
seoDescription: null,
aboutEnabled: false,
aboutFotoUrl: null,
aboutTexto: null,
aniosExperiencia: null,
themePreset: 'pizarra',
themeColor: null,
return row ?? TENANT_PERFIL_FALLBACK;
}
);
export async function getTenantPerfil(): Promise<TenantPerfil> {
const tenantId = await getTenantId();
return getTenantPerfilById(tenantId);
}
// Galería de trabajos del reformista, para gestionarla desde el panel.

View File

@@ -0,0 +1,93 @@
import nodemailer, { type Transporter } from 'nodemailer';
import { env, emailConfigurado } from '@/lib/env';
let _transport: Transporter | null = null;
// Transport perezoso: solo se crea cuando hay SMTP configurado y se va a enviar de verdad.
function getTransport(): Transporter | null {
if (!emailConfigurado()) return null;
if (_transport) return _transport;
const port = Number(env.SMTP_PORT ?? 587);
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port,
secure: port === 465, // 465 = TLS implícito; 587/1025 = STARTTLS/none
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
});
return _transport;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"]/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;',
);
}
// 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: {
to: string;
nombre: string;
empresa: string;
pdf: Buffer;
filename: string;
}): 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>`;
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' }],
});
return true;
} catch (err) {
console.error('enviarPresupuestoEmail error:', err);
return false;
}
}
// Email con el enlace al formulario para subir imágenes (COPY-GUIDE §6.b), usado en el canal
// llamada. Best-effort, mismas garantías que el anterior.
export async function enviarEnlaceFormulario(opts: {
to: string;
nombre: string;
empresa: string;
url: string;
}): 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>`;
try {
await transport.sendMail({
from: env.EMAIL_FROM,
to: opts.to,
subject: `Sube las fotos de tu reforma para ${opts.empresa}`,
html,
});
return true;
} catch (err) {
console.error('enviarEnlaceFormulario error:', err);
return false;
}
}

View File

@@ -12,12 +12,36 @@ const schema = z.object({
RETELL_API_KEY: opcional,
RETELL_AGENT_ID: opcional,
RETELL_FROM_NUMBER: opcional,
// EP de ingesta del lead: clave compartida que valida al llamante externo.
FUNNEL_API_KEY: opcional,
// SMTP para enviar el presupuesto y el enlace al formulario.
SMTP_HOST: opcional,
SMTP_PORT: opcional,
SMTP_USER: opcional,
SMTP_PASS: opcional,
EMAIL_FROM: opcional,
// Webhooks salientes hacia el flujo externo (generación, entrega y arranque de WhatsApp).
PERFIL_WEBHOOK_URL: opcional,
WHATSAPP_WEBHOOK_URL: opcional,
WHATSAPP_START_WEBHOOK_URL: opcional,
// Base pública de la app, para construir enlaces (ej. el enlace al formulario en el email).
APP_URL: opcional,
});
export const env = schema.parse({
RETELL_API_KEY: process.env.RETELL_API_KEY,
RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER,
FUNNEL_API_KEY: process.env.FUNNEL_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
EMAIL_FROM: process.env.EMAIL_FROM,
PERFIL_WEBHOOK_URL: process.env.PERFIL_WEBHOOK_URL,
WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL,
WHATSAPP_START_WEBHOOK_URL: process.env.WHATSAPP_START_WEBHOOK_URL,
APP_URL: process.env.APP_URL,
});
// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede
@@ -26,3 +50,21 @@ export const env = schema.parse({
export function retellConfigurado(): boolean {
return Boolean(env.RETELL_API_KEY && env.RETELL_FROM_NUMBER);
}
// Mínimo para enviar email: host SMTP + remitente. Sin esto el envío degrada a no-op.
export function emailConfigurado(): boolean {
return Boolean(env.SMTP_HOST && env.EMAIL_FROM);
}
// Cada webhook saliente es opcional: si falta su URL, la señal correspondiente no se manda.
export function perfilWebhookConfigurado(): boolean {
return Boolean(env.PERFIL_WEBHOOK_URL);
}
export function whatsappWebhookConfigurado(): boolean {
return Boolean(env.WHATSAPP_WEBHOOK_URL);
}
export function whatsappStartConfigurado(): boolean {
return Boolean(env.WHATSAPP_START_WEBHOOK_URL);
}

View File

@@ -0,0 +1,68 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadPipelineEventos } from '@/db/schema';
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
import { enviarPresupuestoEmail } from '@/lib/email/mailer';
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
export type ResultadoFinalizar = {
ok: boolean;
emailEnviado: boolean;
whatsappSenal: boolean;
};
// Cierra el funnel de un lead cuando ya están las imágenes "después": arma el PDF con la galería
// por zona, lo persiste, lo envía SIEMPRE por email y manda la señal de entrega por WhatsApp al
// flujo externo. Entrega real (la rama simulada de orchestrator.ts:Paso 7 es solo el estado
// intermedio del funnel). Best-effort en email/WhatsApp: el lead avanza igualmente.
export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinalizar> {
const pdf = await construirPresupuestoPdf(leadId);
if (!pdf) return { ok: false, emailEnviado: false, whatsappSenal: false };
const { buffer, filename, lead, tenant } = pdf;
const pdfBase64 = buffer.toString('base64');
await db
.update(leads)
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
.where(eq(leads.id, leadId));
const [emailEnviado, whatsappSenal] = await Promise.all([
enviarPresupuestoEmail({
to: lead.email,
nombre: lead.nombre,
empresa: tenant.nombreEmpresa,
pdf: buffer,
filename,
}),
notificarFlujoWhatsapp({
leadId,
telefono: lead.telefono,
nombre: lead.nombre,
empresa: tenant.nombreEmpresa,
pdfBase64,
filename,
}),
]);
await db
.update(leads)
.set({
pipelineStage: 'whatsapp_entregado',
estado: 'presupuesto_enviado',
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'whatsapp_entregado',
metadata: {
via: [emailEnviado ? 'email' : null, whatsappSenal ? 'whatsapp' : null].filter(Boolean),
emailEnviado,
simulado: !emailEnviado && !whatsappSenal,
},
});
return { ok: true, emailEnviado, whatsappSenal };
}

View File

@@ -0,0 +1,39 @@
import type { Lead, LeadFoto, LeadNota } from '@/db/schema';
export type Zona = NonNullable<Lead['tipoReforma']>;
export type ZonaAgrupada = {
zona: Zona;
antes: LeadFoto[];
despues: LeadFoto[];
notas: LeadNota[];
};
// Agrupa fotos y notas por zona, con fallback al tipo de reforma del lead para las filas sin zona
// (datos antiguos). Conserva el orden en que aparece cada zona. Función pura para poder testearla.
export function agruparPorZona(
fotos: LeadFoto[],
notas: LeadNota[],
tipoLead: Zona,
): ZonaAgrupada[] {
const mapa = new Map<Zona, ZonaAgrupada>();
const slot = (zona: Zona): ZonaAgrupada => {
let s = mapa.get(zona);
if (!s) {
s = { zona, antes: [], despues: [], notas: [] };
mapa.set(zona, s);
}
return s;
};
for (const f of fotos) {
const s = slot((f.zona ?? tipoLead) as Zona);
if (f.momento === 'despues') s.despues.push(f);
else s.antes.push(f);
}
for (const n of notas) {
slot((n.zona ?? tipoLead) as Zona).notas.push(n);
}
return Array.from(mapa.values());
}

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
// Espejo del enum tipo_reforma (src/db/schema.ts). Se mantiene aquí como tupla literal para que
// el schema sea puro (sin importar drizzle) y los tests lo validen sin tocar la DB.
export const ZONAS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
const itemFoto = z.object({
tipo: z.literal('foto'),
zona: z.enum(ZONAS).optional(),
momento: z.enum(['antes', 'despues']).default('antes'),
imagen: z.string().min(1), // data URI o URL http(s)
orden: z.number().int().min(0).optional(),
});
const itemTexto = z.object({
tipo: z.literal('texto'),
zona: z.enum(ZONAS).optional(),
texto: z.string().trim().min(1),
});
// Cuerpo del EP de ingesta: una lista de items (foto o texto) y/o flags. Una llamada vacía
// (sin items ni flag) se rechaza.
export const ingestaBodySchema = z
.object({
items: z.array(z.discriminatedUnion('tipo', [itemFoto, itemTexto])).default([]),
perfilCompleto: z.boolean().optional(),
finalizar: z.boolean().optional(),
})
.refine((b) => b.items.length > 0 || b.perfilCompleto || b.finalizar, {
message: 'Llamada vacía: aporta items o un flag (perfilCompleto/finalizar).',
});
export type IngestaBody = z.infer<typeof ingestaBodySchema>;
export type IngestaItem = IngestaBody['items'][number];

View File

@@ -150,6 +150,9 @@ export async function procesarLead(leadId: string): Promise<void> {
});
// Paso 7: entrega por WhatsApp si el reformista tiene envío automático.
// NOTA: esto es el estado intermedio del funnel (entrega simulada con el presupuesto
// orientativo). La entrega REAL con el PDF (email + señal WhatsApp) ocurre en
// finalizarYEntregar (lib/funnel/finalizar.ts), cuando llegan las imágenes "después".
const envio = await getEnvioModeFor(lead.tenantId);
if (envio === 'automatico') {
await db

View File

@@ -0,0 +1,72 @@
import { asc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { Lead } from '@/db/schema';
import { getTenantPerfilById } from '@/db/tenant-queries';
import { señalarGeneracionPerfil } from '@/lib/webhooks';
type Tipo = NonNullable<Lead['tipoReforma']>;
// Construye el JSON "bien hecho" con toda la data acumulada del lead agrupada por zona y lo manda
// al flujo externo para que genere los renders "después" y homologue el texto con su agente.
// Best-effort: devuelve true solo si el webhook respondió ok. Deja traza en el pipeline.
export async function señalarPerfilCompleto(leadId: string): Promise<boolean> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return false;
const [fotos, notas, tenant] = await Promise.all([
db.select().from(leadFotos).where(eq(leadFotos.leadId, leadId)).orderBy(asc(leadFotos.orden)),
db.select().from(leadNotas).where(eq(leadNotas.leadId, leadId)).orderBy(asc(leadNotas.createdAt)),
getTenantPerfilById(lead.tenantId),
]);
const tipoLead: Tipo = lead.tipoReforma ?? 'otro';
const zonas = new Map<Tipo, { notas: string[]; antes: string[]; despues: string[] }>();
const slot = (zona: Tipo) => {
let s = zonas.get(zona);
if (!s) {
s = { notas: [], antes: [], despues: [] };
zonas.set(zona, s);
}
return s;
};
for (const f of fotos) slot((f.zona ?? tipoLead) as Tipo)[f.momento].push(f.url);
for (const n of notas) {
const t = n.texto.trim();
if (t) slot((n.zona ?? tipoLead) as Tipo).notas.push(t);
}
const payload = {
leadId,
cliente: {
nombre: lead.nombre,
telefono: lead.telefono,
email: lead.email,
provincia: lead.provincia,
},
reforma: {
tipo: lead.tipoReforma,
m2Suelo: lead.m2Suelo,
calidad: lead.calidadGlobal,
estructural: lead.estructural,
urgencia: lead.urgencia,
presupuestoTarget: lead.presupuestoTarget,
},
empresa: { tenantId: lead.tenantId, nombre: tenant.nombreEmpresa },
zonas: Array.from(zonas, ([zona, d]) => ({
zona,
notas: d.notas,
fotos: { antes: d.antes, despues: d.despues },
})),
};
const ok = await señalarGeneracionPerfil(payload);
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'render_generado',
metadata: { perfilCompleto: true, simulado: !ok, zonas: payload.zonas.length },
});
return ok;
}

View File

@@ -137,6 +137,20 @@ const styles = StyleSheet.create({
renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 },
renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 },
renderFootnote: { marginTop: 4, fontSize: 7, color: COLOR.gray400 },
zonasSection: { marginTop: 26 },
zonaBlock: { marginTop: 14 },
zonaTitle: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black, marginBottom: 6 },
zonaImagenes: { flexDirection: 'row', gap: 8 },
zonaCol: { flex: 1 },
zonaColLabel: {
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 3,
},
zonaImage: { width: '100%', height: 150, objectFit: 'cover', borderRadius: 6 },
zonaNota: { marginTop: 4, fontSize: 8, color: COLOR.gray600 },
});
const fmtEuros = (cents: number) =>
@@ -149,6 +163,13 @@ const fmtEuros = (cents: number) =>
const fmtFecha = (date: Date) =>
new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: 'long', year: 'numeric' }).format(date);
export type ZonaPdf = {
zonaLabel: string;
antesSrc: string | null;
despuesSrc: string | null;
notas: string[];
};
export type PresupuestoDocProps = {
empresa: TenantPerfil;
cliente: { nombre: string; telefono: string; provincia: string | null };
@@ -156,6 +177,7 @@ export type PresupuestoDocProps = {
desglose: BudgetResult | null;
logoSrc?: string | null;
render?: { imagenSrc: string; descripcion: string } | null;
zonas?: ZonaPdf[];
};
export function PresupuestoDoc({
@@ -165,6 +187,7 @@ export function PresupuestoDoc({
desglose,
logoSrc,
render,
zonas,
}: PresupuestoDocProps) {
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
@@ -270,6 +293,40 @@ export function PresupuestoDoc({
</View>
) : null}
{zonas && zonas.length > 0 ? (
<View style={styles.zonasSection}>
<Text style={styles.renderTitle}>Imágenes de tu reforma</Text>
{zonas.map((z, i) => (
<View style={styles.zonaBlock} key={`${z.zonaLabel}-${i}`} wrap={false}>
<Text style={styles.zonaTitle}>{z.zonaLabel}</Text>
{z.antesSrc || z.despuesSrc ? (
<View style={styles.zonaImagenes}>
{z.antesSrc ? (
<View style={styles.zonaCol}>
<Text style={styles.zonaColLabel}>Antes</Text>
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */}
<Image src={z.antesSrc} style={styles.zonaImage} />
</View>
) : null}
{z.despuesSrc ? (
<View style={styles.zonaCol}>
<Text style={styles.zonaColLabel}>Después</Text>
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */}
<Image src={z.despuesSrc} style={styles.zonaImage} />
</View>
) : null}
</View>
) : null}
{z.notas.map((n, j) => (
<Text style={styles.zonaNota} key={j}>
{n}
</Text>
))}
</View>
))}
</View>
) : null}
<Text style={styles.footer} fixed>
Presupuesto orientativo. El precio final puede variar según la visita técnica.
{' · '}

View File

@@ -0,0 +1,110 @@
import { asc, eq } from 'drizzle-orm';
import { renderToBuffer } from '@react-pdf/renderer';
import { db } from '@/db';
import { leads, leadFotos, leadNotas } from '@/db/schema';
import type { Lead, LeadFoto, LeadNota } from '@/db/schema';
import { getTenantPerfilById, type TenantPerfil } from '@/db/tenant-queries';
import { TIPO_LABEL } from '@/lib/funnel';
import { PresupuestoDoc, type ZonaPdf } from '@/lib/pdf/PresupuestoDoc';
import { construirDescripcionRender, resolverImagenPdf } from '@/lib/pdf/render-info';
import type { BudgetResult } from '@/budget/types';
import type { AbstractedPreferences } from '@/lib/voice/preferences';
export type PresupuestoPdf = {
buffer: Buffer;
filename: string;
lead: Lead;
tenant: TenantPerfil;
};
type Tipo = NonNullable<Lead['tipoReforma']>;
// Agrupa fotos y notas por zona (con fallback al tipo de reforma del lead) y devuelve, por zona,
// la primera foto "antes" y la primera "despues" convertidas a data URI que @react-pdf puede
// incrustar, más las notas de texto de esa zona.
async function construirZonas(
fotos: LeadFoto[],
notas: LeadNota[],
tipoLead: Tipo,
): Promise<ZonaPdf[]> {
const zonas = new Map<Tipo, { antes: LeadFoto[]; despues: LeadFoto[]; notas: string[] }>();
const slot = (zona: Tipo) => {
let s = zonas.get(zona);
if (!s) {
s = { antes: [], despues: [], notas: [] };
zonas.set(zona, s);
}
return s;
};
for (const f of fotos) {
const zona = (f.zona ?? tipoLead) as Tipo;
slot(zona)[f.momento].push(f);
}
for (const n of notas) {
const texto = n.texto.trim();
if (texto) slot((n.zona ?? tipoLead) as Tipo).notas.push(texto);
}
const resultado: ZonaPdf[] = [];
for (const [zona, data] of zonas) {
const [antesSrc, despuesSrc] = await Promise.all([
resolverImagenPdf(data.antes[0]?.url ?? null, { formato: 'jpeg', maxAncho: 1000 }),
resolverImagenPdf(data.despues[0]?.url ?? null, { formato: 'jpeg', maxAncho: 1000 }),
]);
if (!antesSrc && !despuesSrc && data.notas.length === 0) continue;
resultado.push({ zonaLabel: TIPO_LABEL[zona], antesSrc, despuesSrc, notas: data.notas });
}
return resultado;
}
// Arma el PDF del presupuesto de un lead: desglose real + render + galería antes/después y notas
// por zona. Carga el lead por id sin scoping de tenant (uso interno: lo llaman el route del panel
// y la finalización pública), por eso resuelve el perfil del reformista vía getTenantPerfilById.
export async function construirPresupuestoPdf(leadId: string): Promise<PresupuestoPdf | null> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return null;
const [fotos, notas, tenant] = await Promise.all([
db.select().from(leadFotos).where(eq(leadFotos.leadId, leadId)).orderBy(asc(leadFotos.orden)),
db.select().from(leadNotas).where(eq(leadNotas.leadId, leadId)).orderBy(asc(leadNotas.createdAt)),
getTenantPerfilById(lead.tenantId),
]);
const tipo: Tipo = lead.tipoReforma ?? 'otro';
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const prefs = lead.preferencesSnapshot as AbstractedPreferences | null;
const [logoSrc, renderSrc, zonas] = await Promise.all([
resolverImagenPdf(tenant.logoUrl, { formato: 'png', maxAncho: 400 }),
resolverImagenPdf(lead.renderUrl, { formato: 'jpeg', maxAncho: 1400 }),
construirZonas(fotos, notas, tipo),
]);
const render = renderSrc
? {
imagenSrc: renderSrc,
descripcion: construirDescripcionRender({
calidad: lead.calidadGlobal,
materiales: desglose?.materialesRender ?? [],
estilo: prefs?.estiloRender ?? [],
}),
}
: null;
const buffer = await renderToBuffer(
PresupuestoDoc({
empresa: tenant,
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
reforma: { tipoLabel: lead.tipoReforma ? TIPO_LABEL[tipo] : 'Reforma', fecha: lead.createdAt },
desglose,
logoSrc,
render,
zonas,
}),
);
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return { buffer, filename: `presupuesto-${slug || lead.id}.pdf`, lead, tenant };
}

View File

@@ -0,0 +1,57 @@
import {
env,
perfilWebhookConfigurado,
whatsappWebhookConfigurado,
whatsappStartConfigurado,
} from '@/lib/env';
// POST JSON best-effort: nunca lanza. Devuelve true solo si el destino respondió 2xx.
export async function postWebhook(url: string, payload: unknown): Promise<boolean> {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error(`webhook ${url}${res.status}`);
return false;
}
return true;
} catch (err) {
console.error(`webhook ${url} error:`, err);
return false;
}
}
// Señal al flujo externo de que el perfil del lead está completo: que genere renders y homologue
// el texto con su agente. El payload lo arma señalarPerfilCompleto (lib/funnel/perfil.ts).
export async function señalarGeneracionPerfil(payload: unknown): Promise<boolean> {
if (!perfilWebhookConfigurado()) return false;
return postWebhook(env.PERFIL_WEBHOOK_URL!, payload);
}
// Señal de entrega: el PDF está listo, que el flujo externo lo mande por WhatsApp al cliente.
export async function notificarFlujoWhatsapp(payload: {
leadId: string;
telefono: string;
nombre: string;
empresa: string;
pdfBase64: string;
filename: string;
}): Promise<boolean> {
if (!whatsappWebhookConfigurado()) return false;
return postWebhook(env.WHATSAPP_WEBHOOK_URL!, payload);
}
// Señal de arranque: que el flujo externo escriba el primer mensaje de WhatsApp al lead para
// continuar la personalización por ese canal.
export async function iniciarConversacionWhatsapp(payload: {
leadId: string;
telefono: string;
nombre: string;
empresa: string;
}): Promise<boolean> {
if (!whatsappStartConfigurado()) return false;
return postWebhook(env.WHATSAPP_START_WEBHOOK_URL!, payload);
}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
describe('ingestaBodySchema', () => {
it('acepta una foto con zona y momento por defecto "antes"', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'bano', imagen: 'data:image/png;base64,AAA' }],
});
expect(r.success).toBe(true);
if (r.success) {
const foto = r.data.items[0];
expect(foto.tipo).toBe('foto');
if (foto.tipo === 'foto') expect(foto.momento).toBe('antes');
}
});
it('acepta momento "despues" explícito', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'cocina', momento: 'despues', imagen: 'https://x/y.jpg' }],
});
expect(r.success).toBe(true);
});
it('acepta una nota de texto y zona opcional', () => {
const r = ingestaBodySchema.safeParse({ items: [{ tipo: 'texto', texto: 'suelo premium' }] });
expect(r.success).toBe(true);
});
it('acepta items mixtos foto + texto', () => {
const r = ingestaBodySchema.safeParse({
items: [
{ tipo: 'foto', zona: 'bano', imagen: 'data:image/png;base64,AAA' },
{ tipo: 'texto', zona: 'bano', texto: 'suelo premium' },
],
});
expect(r.success).toBe(true);
});
it('acepta una llamada solo con flag perfilCompleto (sin items)', () => {
const r = ingestaBodySchema.safeParse({ perfilCompleto: true });
expect(r.success).toBe(true);
if (r.success) expect(r.data.items).toEqual([]);
});
it('acepta una llamada solo con flag finalizar', () => {
expect(ingestaBodySchema.safeParse({ finalizar: true }).success).toBe(true);
});
it('rechaza una llamada totalmente vacía', () => {
expect(ingestaBodySchema.safeParse({}).success).toBe(false);
expect(ingestaBodySchema.safeParse({ items: [] }).success).toBe(false);
});
it('rechaza un tipo de item inválido', () => {
const r = ingestaBodySchema.safeParse({ items: [{ tipo: 'video', imagen: 'x' }] });
expect(r.success).toBe(false);
});
it('rechaza una zona fuera del enum', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'jardin', imagen: 'data:...' }],
});
expect(r.success).toBe(false);
});
it('rechaza foto sin imagen y texto vacío', () => {
expect(ingestaBodySchema.safeParse({ items: [{ tipo: 'foto', imagen: '' }] }).success).toBe(
false,
);
expect(ingestaBodySchema.safeParse({ items: [{ tipo: 'texto', texto: ' ' }] }).success).toBe(
false,
);
});
});

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { agruparPorZona } from '@/lib/funnel/fotos';
import type { LeadFoto, LeadNota } from '@/db/schema';
function foto(p: Partial<LeadFoto>): LeadFoto {
return {
id: Math.random().toString(36).slice(2),
leadId: 'lead-1',
url: 'data:img',
momento: 'antes',
zona: null,
orden: 0,
createdAt: new Date(),
...p,
} as LeadFoto;
}
function nota(p: Partial<LeadNota>): LeadNota {
return {
id: Math.random().toString(36).slice(2),
leadId: 'lead-1',
zona: null,
texto: 'x',
origen: 'ep',
createdAt: new Date(),
...p,
} as LeadNota;
}
describe('agruparPorZona', () => {
it('separa antes y después dentro de cada zona', () => {
const grupos = agruparPorZona(
[
foto({ zona: 'bano', momento: 'antes' }),
foto({ zona: 'bano', momento: 'despues' }),
foto({ zona: 'bano', momento: 'antes' }),
],
[],
'otro',
);
expect(grupos).toHaveLength(1);
expect(grupos[0].zona).toBe('bano');
expect(grupos[0].antes).toHaveLength(2);
expect(grupos[0].despues).toHaveLength(1);
});
it('agrupa por zona distintas y mantiene el orden de aparición', () => {
const grupos = agruparPorZona(
[foto({ zona: 'cocina' }), foto({ zona: 'bano' }), foto({ zona: 'cocina' })],
[],
'otro',
);
expect(grupos.map((g) => g.zona)).toEqual(['cocina', 'bano']);
expect(grupos[0].antes).toHaveLength(2);
});
it('usa el tipo del lead como fallback cuando la zona es null', () => {
const grupos = agruparPorZona([foto({ zona: null })], [nota({ zona: null })], 'salon');
expect(grupos).toHaveLength(1);
expect(grupos[0].zona).toBe('salon');
expect(grupos[0].antes).toHaveLength(1);
expect(grupos[0].notas).toHaveLength(1);
});
it('asocia las notas a su zona', () => {
const grupos = agruparPorZona(
[foto({ zona: 'cocina' })],
[nota({ zona: 'cocina', texto: 'encimera de cuarzo' }), nota({ zona: 'bano', texto: 'ducha' })],
'otro',
);
const cocina = grupos.find((g) => g.zona === 'cocina');
const bano = grupos.find((g) => g.zona === 'bano');
expect(cocina?.notas.map((n) => n.texto)).toEqual(['encimera de cuarzo']);
expect(bano?.notas.map((n) => n.texto)).toEqual(['ducha']);
expect(bano?.antes).toHaveLength(0);
});
it('devuelve vacío sin fotos ni notas', () => {
expect(agruparPorZona([], [], 'cocina')).toEqual([]);
});
});