Compare commits
11 Commits
cd6532eb1b
...
5df608f203
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df608f203 | ||
|
|
0a5f8cba2b | ||
|
|
9b5b0d59a6 | ||
|
|
f87a3ecd81 | ||
|
|
35ba2f28fe | ||
|
|
ae8984fe13 | ||
|
|
195ecf6cc3 | ||
|
|
ec09267d99 | ||
|
|
bcc882e37d | ||
|
|
737496ed89 | ||
|
|
b9dd90f4ef |
@@ -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 |
|
||||
|
||||
@@ -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
163
mvp/b2c/api-docs/README.md
Normal 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`.
|
||||
@@ -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;
|
||||
|
||||
13
mvp/b2c/drizzle/0008_sharp_bloodaxe.sql
Normal file
13
mvp/b2c/drizzle/0008_sharp_bloodaxe.sql
Normal 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;
|
||||
1673
mvp/b2c/drizzle/meta/0008_snapshot.json
Normal file
1673
mvp/b2c/drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1780313493522,
|
||||
"tag": "0007_pale_chat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1780505942614,
|
||||
"tag": "0008_sharp_bloodaxe",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
21
mvp/b2c/package-lock.json
generated
21
mvp/b2c/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
102
mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts
Normal file
102
mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
39
mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx
Normal file
39
mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
38
mvp/b2c/src/app/solicitud/[id]/llamada/page.tsx
Normal file
38
mvp/b2c/src/app/solicitud/[id]/llamada/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
mvp/b2c/src/app/solicitud/[id]/page.tsx
Normal file
82
mvp/b2c/src/app/solicitud/[id]/page.tsx
Normal 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">
|
||||
Tú 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
mvp/b2c/src/app/solicitud/[id]/whatsapp/page.tsx
Normal file
32
mvp/b2c/src/app/solicitud/[id]/whatsapp/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
124
mvp/b2c/src/components/funnel/CanalLlamada.tsx
Normal file
124
mvp/b2c/src/components/funnel/CanalLlamada.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
mvp/b2c/src/components/funnel/CanalWhatsapp.tsx
Normal file
76
mvp/b2c/src/components/funnel/CanalWhatsapp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
mvp/b2c/src/components/funnel/FormularioZonas.tsx
Normal file
286
mvp/b2c/src/components/funnel/FormularioZonas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
56
mvp/b2c/src/components/panel/LeadFotosGaleria.tsx
Normal file
56
mvp/b2c/src/components/panel/LeadFotosGaleria.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
93
mvp/b2c/src/lib/email/mailer.ts
Normal file
93
mvp/b2c/src/lib/email/mailer.ts
Normal 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 === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"',
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
68
mvp/b2c/src/lib/funnel/finalizar.ts
Normal file
68
mvp/b2c/src/lib/funnel/finalizar.ts
Normal 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 };
|
||||
}
|
||||
39
mvp/b2c/src/lib/funnel/fotos.ts
Normal file
39
mvp/b2c/src/lib/funnel/fotos.ts
Normal 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());
|
||||
}
|
||||
34
mvp/b2c/src/lib/funnel/ingesta-schema.ts
Normal file
34
mvp/b2c/src/lib/funnel/ingesta-schema.ts
Normal 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];
|
||||
@@ -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
|
||||
|
||||
72
mvp/b2c/src/lib/funnel/perfil.ts
Normal file
72
mvp/b2c/src/lib/funnel/perfil.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
{' · '}
|
||||
|
||||
110
mvp/b2c/src/lib/pdf/build-presupuesto.ts
Normal file
110
mvp/b2c/src/lib/pdf/build-presupuesto.ts
Normal 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 };
|
||||
}
|
||||
57
mvp/b2c/src/lib/webhooks.ts
Normal file
57
mvp/b2c/src/lib/webhooks.ts
Normal 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);
|
||||
}
|
||||
74
mvp/b2c/tests/api/ingesta-schema.test.ts
Normal file
74
mvp/b2c/tests/api/ingesta-schema.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
81
mvp/b2c/tests/panel/agrupar-zonas.test.ts
Normal file
81
mvp/b2c/tests/panel/agrupar-zonas.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user