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*
|
- **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)
|
### Subida de fotos (paso 2 del wizard)
|
||||||
|
|
||||||
- **Título del paso:** Ahora una foto de tu espacio actual
|
- **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
|
## 7. Microcopy del panel del reformista
|
||||||
|
|
||||||
| Elemento | Texto |
|
| Elemento | Texto |
|
||||||
|
|||||||
@@ -7,3 +7,26 @@ DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
|
|||||||
RETELL_API_KEY=""
|
RETELL_API_KEY=""
|
||||||
RETELL_AGENT_ID=""
|
RETELL_AGENT_ID=""
|
||||||
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000
|
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"."calidad" AS ENUM('basica', 'media', 'premium');
|
||||||
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');
|
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');
|
||||||
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');
|
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"."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"."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');
|
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,
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
"lead_id" uuid NOT NULL,
|
"lead_id" uuid NOT NULL,
|
||||||
"url" text NOT NULL,
|
"url" text NOT NULL,
|
||||||
|
"momento" "foto_momento" DEFAULT 'antes' NOT NULL,
|
||||||
|
"zona" "tipo_reforma",
|
||||||
"orden" integer DEFAULT 0 NOT NULL,
|
"orden" integer DEFAULT 0 NOT NULL,
|
||||||
"created_at" timestamp with time zone DEFAULT now() 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" (
|
CREATE TABLE "lead_pipeline_eventos" (
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
"lead_id" 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 "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_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_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 "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 "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;
|
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,
|
"when": 1780313493522,
|
||||||
"tag": "0007_pale_chat",
|
"tag": "0007_pale_chat",
|
||||||
"breakpoints": true
|
"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",
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
@@ -2966,6 +2968,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.15",
|
"version": "19.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||||
@@ -7201,6 +7213,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/normalize-svg-path": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@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 EstadoControl from '@/components/panel/EstadoControl';
|
||||||
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||||
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
|
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
|
||||||
|
import LeadFotosGaleria from '@/components/panel/LeadFotosGaleria';
|
||||||
import {
|
import {
|
||||||
PIPELINE_LABEL,
|
PIPELINE_LABEL,
|
||||||
PIPELINE_NEXT,
|
PIPELINE_NEXT,
|
||||||
@@ -32,7 +33,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
const data = await getLead(id);
|
const data = await getLead(id);
|
||||||
if (!data) notFound();
|
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 reachedStages = new Set(eventos.map((e) => e.stage));
|
||||||
|
|
||||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||||
@@ -275,15 +276,10 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fotos subidas */}
|
{/* Fotos y notas por zona */}
|
||||||
{fotos.length > 0 && (
|
{(fotos.length > 0 || notas.length > 0) && (
|
||||||
<Section title="Fotos subidas por el cliente">
|
<Section title="Fotos y detalles por zona">
|
||||||
<div className="flex flex-wrap gap-3">
|
<LeadFotosGaleria fotos={fotos} notas={notas} tipoLead={lead.tipoReforma} />
|
||||||
{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>
|
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { renderToBuffer } from '@react-pdf/renderer';
|
|
||||||
import { getLead } from '@/db/queries';
|
import { getLead } from '@/db/queries';
|
||||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
|
||||||
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';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -16,52 +10,18 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
// getLead aplica el scoping por tenant del panel: sirve de guardia de auth/404.
|
||||||
const data = await getLead(id);
|
const data = await getLead(id);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const pdf = await construirPresupuestoPdf(id);
|
||||||
|
if (!pdf) notFound();
|
||||||
|
|
||||||
const descargar = new URL(req.url).searchParams.get('download') === '1';
|
const descargar = new URL(req.url).searchParams.get('download') === '1';
|
||||||
|
return new Response(new Uint8Array(pdf.buffer), {
|
||||||
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), {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/pdf',
|
'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',
|
'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 { redirect } 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';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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 { id } = await params;
|
||||||
const data = await getPublicLead(id);
|
redirect(`/solicitud/${id}/formulario`);
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { redirect } from 'next/navigation';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { db } from '@/db';
|
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 { getTenantBySlug } from '@/lib/funnel/public-queries';
|
||||||
|
import { getTenantPerfilById } from '@/db/tenant-queries';
|
||||||
import { procesarLead } from '@/lib/funnel/orchestrator';
|
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 MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
|
||||||
|
|
||||||
const crearLeadSchema = z.object({
|
const crearLeadSchema = z.object({
|
||||||
@@ -79,27 +87,57 @@ async function fileToDataUri(file: File): Promise<string | null> {
|
|||||||
return `data:${file.type};base64,${buffer.toString('base64')}`;
|
return `data:${file.type};base64,${buffer.toString('base64')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paso 3 del funnel: el cliente sube fotos y confirma los datos clave de la reforma.
|
const CALIDAD_RANK: Record<(typeof CALIDADES)[number], number> = { basica: 0, media: 1, premium: 2 };
|
||||||
// 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.
|
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> {
|
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
|
||||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||||
if (!lead) throw new Error('Solicitud no encontrada.');
|
if (!lead) throw new Error('Solicitud no encontrada.');
|
||||||
const tenantId = lead.tenantId;
|
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 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 urgenciaRaw = String(formData.get('urgencia') ?? '');
|
||||||
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
|
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
|
||||||
? (urgenciaRaw as 'alta' | 'media' | 'baja')
|
? (urgenciaRaw as 'alta' | 'media' | 'baja')
|
||||||
@@ -108,20 +146,40 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
const presupuestoTarget =
|
const presupuestoTarget =
|
||||||
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
||||||
const estructural = formData.get('estructural') === 'on';
|
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);
|
let zonas = await parsearZonas(formData);
|
||||||
const dataUris: string[] = [];
|
if (zonas.length === 0) {
|
||||||
for (const file of archivos.slice(0, MAX_FOTOS)) {
|
zonas = [{ tipo: 'otro', m2: null, calidad: 'media', notas: null, fotos: [] }];
|
||||||
const uri = await fileToDataUri(file);
|
|
||||||
if (uri) dataUris.push(uri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataUris.length > 0) {
|
// Inserta fotos (antes, por zona) y notas (por zona) en la estructura del lead.
|
||||||
await db.insert(leadFotos).values(
|
const fotoRows: NewLeadFoto[] = [];
|
||||||
dataUris.map((url, orden) => ({ leadId, url, orden }))
|
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
|
await db
|
||||||
.update(leads)
|
.update(leads)
|
||||||
@@ -142,12 +200,90 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
await db.insert(leadPipelineEventos).values({
|
await db.insert(leadPipelineEventos).values({
|
||||||
leadId,
|
leadId,
|
||||||
stage: 'fotos_subidas',
|
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);
|
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');
|
revalidatePath('/panel');
|
||||||
redirect(`/solicitud/${leadId}/estado`);
|
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);
|
setSubmitError(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/solicitud/${result.leadId}/fotos`);
|
router.push(`/solicitud/${result.leadId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function LeadForm({ slug }: { slug: string }) {
|
|||||||
setSubmitError(result.error);
|
setSubmitError(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/solicitud/${result.leadId}/fotos`);
|
router.push(`/solicitud/${result.leadId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
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 {
|
import {
|
||||||
leads,
|
leads,
|
||||||
leadFotos,
|
leadFotos,
|
||||||
|
leadNotas,
|
||||||
leadEstadoHistory,
|
leadEstadoHistory,
|
||||||
leadPipelineEventos,
|
leadPipelineEventos,
|
||||||
precisionHistory,
|
precisionHistory,
|
||||||
@@ -31,8 +32,9 @@ export async function getLead(id: string) {
|
|||||||
|
|
||||||
if (!lead) return null;
|
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(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)),
|
||||||
|
db.select().from(leadNotas).where(eq(leadNotas.leadId, id)).orderBy(asc(leadNotas.createdAt)),
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(leadPipelineEventos)
|
.from(leadPipelineEventos)
|
||||||
@@ -46,7 +48,7 @@ export async function getLead(id: string) {
|
|||||||
db.select().from(precisionHistory).where(eq(precisionHistory.leadId, id)),
|
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() {
|
export async function getResumen() {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export const tipoReforma = pgEnum('tipo_reforma', [
|
|||||||
|
|
||||||
export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']);
|
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 urgencia = pgEnum('urgencia', ['alta', 'media', 'baja']);
|
||||||
|
|
||||||
export const categoriaMaterial = pgEnum('categoria_material', [
|
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', {
|
export const leadFotos = pgTable('lead_fotos', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
leadId: uuid('lead_id')
|
leadId: uuid('lead_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => leads.id, { onDelete: 'cascade' }),
|
.references(() => leads.id, { onDelete: 'cascade' }),
|
||||||
url: text('url').notNull(),
|
url: text('url').notNull(),
|
||||||
|
momento: fotoMomento('momento').notNull().default('antes'),
|
||||||
|
zona: tipoReforma('zona'),
|
||||||
orden: integer('orden').notNull().default(0),
|
orden: integer('orden').notNull().default(0),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
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]).
|
// 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.
|
// El reformista las solicita desde el panel y aprueba antes de que salgan en su landing.
|
||||||
export const testimonios = pgTable(
|
export const testimonios = pgTable(
|
||||||
@@ -350,6 +370,9 @@ export type Tenant = typeof tenants.$inferSelect;
|
|||||||
export type Lead = typeof leads.$inferSelect;
|
export type Lead = typeof leads.$inferSelect;
|
||||||
export type NewLead = typeof leads.$inferInsert;
|
export type NewLead = typeof leads.$inferInsert;
|
||||||
export type LeadFoto = typeof leadFotos.$inferSelect;
|
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 Testimonio = typeof testimonios.$inferSelect;
|
||||||
export type NewTestimonio = typeof testimonios.$inferInsert;
|
export type NewTestimonio = typeof testimonios.$inferInsert;
|
||||||
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;
|
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;
|
||||||
|
|||||||
@@ -23,8 +23,29 @@ export type TenantPerfil = {
|
|||||||
themeColor: string | null;
|
themeColor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
const TENANT_PERFIL_FALLBACK: TenantPerfil = {
|
||||||
const tenantId = await getTenantId();
|
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
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
nombreEmpresa: tenants.nombreEmpresa,
|
nombreEmpresa: tenants.nombreEmpresa,
|
||||||
@@ -49,27 +70,12 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
|||||||
.where(eq(tenants.id, tenantId))
|
.where(eq(tenants.id, tenantId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return (
|
return row ?? TENANT_PERFIL_FALLBACK;
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
return getTenantPerfilById(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Galería de trabajos del reformista, para gestionarla desde el panel.
|
// 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_API_KEY: opcional,
|
||||||
RETELL_AGENT_ID: opcional,
|
RETELL_AGENT_ID: opcional,
|
||||||
RETELL_FROM_NUMBER: 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({
|
export const env = schema.parse({
|
||||||
RETELL_API_KEY: process.env.RETELL_API_KEY,
|
RETELL_API_KEY: process.env.RETELL_API_KEY,
|
||||||
RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
|
RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
|
||||||
RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER,
|
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
|
// 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 {
|
export function retellConfigurado(): boolean {
|
||||||
return Boolean(env.RETELL_API_KEY && env.RETELL_FROM_NUMBER);
|
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.
|
// 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);
|
const envio = await getEnvioModeFor(lead.tenantId);
|
||||||
if (envio === 'automatico') {
|
if (envio === 'automatico') {
|
||||||
await db
|
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 },
|
renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 },
|
||||||
renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 },
|
renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 },
|
||||||
renderFootnote: { marginTop: 4, fontSize: 7, color: COLOR.gray400 },
|
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) =>
|
const fmtEuros = (cents: number) =>
|
||||||
@@ -149,6 +163,13 @@ const fmtEuros = (cents: number) =>
|
|||||||
const fmtFecha = (date: Date) =>
|
const fmtFecha = (date: Date) =>
|
||||||
new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: 'long', year: 'numeric' }).format(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 = {
|
export type PresupuestoDocProps = {
|
||||||
empresa: TenantPerfil;
|
empresa: TenantPerfil;
|
||||||
cliente: { nombre: string; telefono: string; provincia: string | null };
|
cliente: { nombre: string; telefono: string; provincia: string | null };
|
||||||
@@ -156,6 +177,7 @@ export type PresupuestoDocProps = {
|
|||||||
desglose: BudgetResult | null;
|
desglose: BudgetResult | null;
|
||||||
logoSrc?: string | null;
|
logoSrc?: string | null;
|
||||||
render?: { imagenSrc: string; descripcion: string } | null;
|
render?: { imagenSrc: string; descripcion: string } | null;
|
||||||
|
zonas?: ZonaPdf[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PresupuestoDoc({
|
export function PresupuestoDoc({
|
||||||
@@ -165,6 +187,7 @@ export function PresupuestoDoc({
|
|||||||
desglose,
|
desglose,
|
||||||
logoSrc,
|
logoSrc,
|
||||||
render,
|
render,
|
||||||
|
zonas,
|
||||||
}: PresupuestoDocProps) {
|
}: PresupuestoDocProps) {
|
||||||
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
|
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
@@ -270,6 +293,40 @@ export function PresupuestoDoc({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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>
|
<Text style={styles.footer} fixed>
|
||||||
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
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