From 8de139f9d3efe3adb8b743f839ba9477d045a400 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Tue, 2 Jun 2026 19:48:14 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Mejora=20el=20PDF=20de=20presupuesto:=20dis?= =?UTF-8?q?claimer,=20render=20y=20conversi=C3=B3n=20de=20im=C3=A1genes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añade bajo el título un párrafo orientativo: el precio final se fija tras la visita gratuita y la estimación se basa en datos estadísticos ajustados para acercarse lo máximo posible al importe definitivo. - Añade una sección con el render del resultado y una descripción generada a partir de los materiales (materialesRender) y el estilo de la llamada, con fallback elegante cuando faltan datos. - @react-pdf solo incrusta PNG/JPEG: convierte con sharp los WebP/SVG (render y logo) que antes se descartaban en silencio dejando el PDF sin imagen. El render va a JPEG redimensionado (PDF ~360 KB en vez de ~2,7 MB) y el logo a PNG para conservar transparencia. - Fija sharp como dependencia directa (ya venía como transitiva de Next). - Copy nuevo añadido primero a COPY-GUIDE.md (sección entrega del presupuesto). Co-Authored-By: Claude Opus 4.7 --- copy/COPY-GUIDE.md | 11 ++ mvp/b2c/package-lock.json | 4 +- mvp/b2c/package.json | 1 + .../src/app/panel/[id]/presupuesto/route.ts | 20 ++++ mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx | 39 ++++++- mvp/b2c/src/lib/pdf/render-info.ts | 109 ++++++++++++++++++ 6 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 mvp/b2c/src/lib/pdf/render-info.ts diff --git a/copy/COPY-GUIDE.md b/copy/COPY-GUIDE.md index e9ae178..71f4107 100644 --- a/copy/COPY-GUIDE.md +++ b/copy/COPY-GUIDE.md @@ -478,6 +478,17 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll > 👉 *¿Te gustaría que [Reformista] vaya a verlo gratis?* > [Botón: Sí, pídeme la visita] [Botón: Tengo dudas, contestadme] +### PDF del presupuesto (documento adjunto) + +Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel. Mantiene el tono orientativo y honesto: deja claro que es una aproximación, sin restarle credibilidad. + +- **Título:** *PRESUPUESTO ORIENTATIVO DE REFORMA* +- **Disclaimer (bajo el título):** *El precio final se determinará tras la visita gratuita de [Reformista] en tu casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos para que se acerque lo máximo posible al importe definitivo.* +- **Sección render — título:** *Así quedaría tu reforma* +- **Render — descripción (se genera según la selección del cliente):** *Recreación con calidad [media] y acabados en [suelo porcelánico, paredes en tono neutro, mobiliario lacado]. Estilo [moderno y luminoso] según tus preferencias.* +- **Render — descripción (fallback si faltan materiales o estilo):** *Recreación orientativa de cómo quedaría tu espacio reformado con la calidad [media] seleccionada.* +- **Pie de la imagen:** *Render orientativo generado con IA. El resultado real puede variar según los materiales finales y las condiciones de la obra.* + ### WhatsApp follow-up (24h sin respuesta) > 👋 *Hola [Nombre], ¿pudiste mirar el presupuesto que te mandamos ayer?* diff --git a/mvp/b2c/package-lock.json b/mvp/b2c/package-lock.json index 568ee9b..59b385a 100644 --- a/mvp/b2c/package-lock.json +++ b/mvp/b2c/package-lock.json @@ -18,6 +18,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-easy-crop": "^5.5.7", + "sharp": "^0.34.5", "tailwindcss": "^4.3.0", "zod": "^4.4.3" }, @@ -1437,7 +1438,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -7980,7 +7980,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -8024,7 +8023,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/mvp/b2c/package.json b/mvp/b2c/package.json index 633a7a3..3ae9eb1 100644 --- a/mvp/b2c/package.json +++ b/mvp/b2c/package.json @@ -27,6 +27,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-easy-crop": "^5.5.7", + "sharp": "^0.34.5", "tailwindcss": "^4.3.0", "zod": "^4.4.3" }, diff --git a/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts b/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts index 5d265af..3e76f75 100644 --- a/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts +++ b/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts @@ -4,7 +4,9 @@ import { getLead } from '@/db/queries'; import { getTenantPerfil } from '@/db/tenant-queries'; import { TIPO_LABEL } from '@/lib/funnel'; import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc'; +import { construirDescripcionRender, resolverImagenPdf } from '@/lib/pdf/render-info'; import type { BudgetResult } from '@/budget/types'; +import type { AbstractedPreferences } from '@/lib/voice/preferences'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -25,6 +27,22 @@ export async function GET( 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, @@ -34,6 +52,8 @@ export async function GET( fecha: lead.createdAt, }, desglose, + logoSrc, + render, }) ); diff --git a/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx b/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx index 40760c4..34ab913 100644 --- a/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx +++ b/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx @@ -131,6 +131,12 @@ const styles = StyleSheet.create({ textAlign: 'center', }, empty: { marginTop: 40, fontSize: 11, color: COLOR.gray400, textAlign: 'center' }, + disclaimer: { marginTop: 6, fontSize: 8.5, color: COLOR.gray600, lineHeight: 1.45 }, + renderSection: { marginTop: 26 }, + renderTitle: { fontSize: 11, fontFamily: 'Helvetica-Bold', color: COLOR.black, marginBottom: 8 }, + renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 }, + renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 }, + renderFootnote: { marginTop: 4, fontSize: 7, color: COLOR.gray400 }, }); const fmtEuros = (cents: number) => @@ -148,9 +154,18 @@ export type PresupuestoDocProps = { cliente: { nombre: string; telefono: string; provincia: string | null }; reforma: { tipoLabel: string; fecha: Date }; desglose: BudgetResult | null; + logoSrc?: string | null; + render?: { imagenSrc: string; descripcion: string } | null; }; -export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: PresupuestoDocProps) { +export function PresupuestoDoc({ + empresa, + cliente, + reforma, + desglose, + logoSrc, + render, +}: PresupuestoDocProps) { const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · '); return ( @@ -166,13 +181,18 @@ export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: Presupue {empresa.direccion ? {empresa.direccion} : null} {contacto ? {contacto} : null} - {empresa.logoUrl ? ( + {logoSrc ? ( // eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop - + ) : null} PRESUPUESTO ORIENTATIVO DE REFORMA + + El precio final se determinará tras la visita gratuita de {empresa.nombreEmpresa} en tu + casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos + para que se acerque lo máximo posible al importe definitivo. + @@ -237,6 +257,19 @@ export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: Presupue Este lead aún no tiene presupuesto calculado. )} + {render ? ( + + Así quedaría tu reforma + {/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */} + + {render.descripcion} + + Render orientativo generado con IA. El resultado real puede variar según los + materiales finales y las condiciones de la obra. + + + ) : null} + Presupuesto orientativo. El precio final puede variar según la visita técnica. {' · '} diff --git a/mvp/b2c/src/lib/pdf/render-info.ts b/mvp/b2c/src/lib/pdf/render-info.ts new file mode 100644 index 0000000..dd3f083 --- /dev/null +++ b/mvp/b2c/src/lib/pdf/render-info.ts @@ -0,0 +1,109 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; +import type { Calidad } from '@/budget/types'; +import { CALIDAD_LABEL } from '@/lib/funnel'; + +const MIME: Record = { + '.webp': 'image/webp', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', +}; + +// @react-pdf solo pinta PNG y JPEG. Cualquier otro formato (WebP de nuestros uploads y +// assets, SVG de los logos…) se rasteriza con sharp; si no, la imagen se descarta en +// silencio y el PDF sale sin ella. El render va a JPEG (foto, sin transparencia → ligero) +// y el logo a PNG (conserva transparencia). +const PASSTHROUGH = new Set(['image/png', 'image/jpeg']); + +type OpcionesImagen = { formato?: 'png' | 'jpeg'; maxAncho?: number }; + +async function bufferADataUri(buf: Buffer, mime: string, opts: OpcionesImagen): Promise { + if (PASSTHROUGH.has(mime)) { + return `data:${mime};base64,${buf.toString('base64')}`; + } + const pipe = sharp(buf, { density: 200 }).resize({ + width: opts.maxAncho ?? 1400, + withoutEnlargement: true, + }); + if (opts.formato === 'jpeg') { + const jpeg = await pipe.flatten({ background: '#ffffff' }).jpeg({ quality: 80 }).toBuffer(); + return `data:image/jpeg;base64,${jpeg.toString('base64')}`; + } + const png = await pipe.png().toBuffer(); + return `data:image/png;base64,${png.toString('base64')}`; +} + +// Convierte el origen de una imagen (renderUrl, logoUrl) en un data URI PNG/JPEG que +// @react-pdf pueda incrustar. Acepta data URIs, rutas relativas a /public y URLs http(s). +export async function resolverImagenPdf( + url: string | null, + opts: OpcionesImagen = {} +): Promise { + if (!url) return null; + + if (url.startsWith('data:')) { + const coma = url.indexOf(','); + if (coma === -1) return null; + const cabecera = url.slice(5, coma); + const mime = cabecera.split(';')[0] || 'application/octet-stream'; + const datos = url.slice(coma + 1); + const buf = /;base64/i.test(cabecera) + ? Buffer.from(datos, 'base64') + : Buffer.from(decodeURIComponent(datos)); + try { + return await bufferADataUri(buf, mime, opts); + } catch { + return null; + } + } + + // La pipeline de render entrega PNG/JPEG; las URLs remotas se pasan tal cual. + if (url.startsWith('http://') || url.startsWith('https://')) return url; + + if (!url.startsWith('/')) return null; + const publicDir = path.join(process.cwd(), 'public'); + const abs = path.normalize(path.join(publicDir, url)); + if (abs !== publicDir && !abs.startsWith(publicDir + path.sep)) return null; + + const mime = MIME[path.extname(abs).toLowerCase()]; + if (!mime) return null; + try { + return await bufferADataUri(await readFile(abs), mime, opts); + } catch { + return null; + } +} + +function listaNatural(items: string[]): string { + if (items.length <= 1) return items[0] ?? ''; + return `${items.slice(0, -1).join(', ')} y ${items[items.length - 1]}`; +} + +// Describe el render combinando los acabados (materialesRender del desglose) con el +// estilo detectado en la llamada. Si falta cualquiera de los dos, degrada con elegancia. +export function construirDescripcionRender(opts: { + calidad: Calidad | null; + materiales: string[]; + estilo: string[]; +}): string { + const calidad = CALIDAD_LABEL[opts.calidad ?? 'media'].toLowerCase(); + const materiales = opts.materiales.map((s) => s.trim()).filter(Boolean).slice(0, 4); + const estilo = opts.estilo.map((s) => s.trim()).filter(Boolean).slice(0, 3); + + if (materiales.length === 0 && estilo.length === 0) { + return `Recreación orientativa de cómo quedaría tu espacio reformado con la ${calidad} seleccionada.`; + } + + const partes = [ + materiales.length > 0 + ? `Recreación con ${calidad} y acabados en ${listaNatural(materiales)}.` + : `Recreación con ${calidad}.`, + ]; + if (estilo.length > 0) { + partes.push(`Estilo ${listaNatural(estilo)} según tus preferencias.`); + } + return partes.join(' '); +} From 372ad560bfcb45527aee0b1c41d35163dd3d444c Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Tue, 2 Jun 2026 21:55:29 +0200 Subject: [PATCH 2/2] =?UTF-8?q?A=C3=B1ade=20llamada=20saliente=20con=20Ret?= =?UTF-8?q?ell=20al=20funnel=20B2C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integra el agente de voz de Retell (Arquitectura A para la demo): tras la pre-llamada, el orquestador lanza una llamada saliente real con override de agente y dynamic variables (empresa + cliente), mientras el render y el presupuesto se siguen generando con los datos del formulario. - src/lib/env.ts: esquema zod de RETELL_* (claves opcionales -> sin ellas el funnel sigue en modo simulado y el build no se rompe) - src/lib/voice/retell.ts: cliente fino con fetch a create-phone-call (sin nueva dependencia), normalización E.164 y builder de variables - orchestrator: dispara la llamada best-effort y guarda el callId - tests del normalizador de teléfono y del builder de variables Co-Authored-By: Claude Opus 4.7 --- mvp/b2c/.env.example | 6 ++ mvp/b2c/src/lib/env.ts | 28 ++++++++++ mvp/b2c/src/lib/funnel/orchestrator.ts | 25 ++++++++- mvp/b2c/src/lib/voice/retell.ts | 77 ++++++++++++++++++++++++++ mvp/b2c/tests/voice/retell.test.ts | 63 +++++++++++++++++++++ 5 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 mvp/b2c/src/lib/env.ts create mode 100644 mvp/b2c/src/lib/voice/retell.ts create mode 100644 mvp/b2c/tests/voice/retell.test.ts diff --git a/mvp/b2c/.env.example b/mvp/b2c/.env.example index b65cec2..cc8916c 100644 --- a/mvp/b2c/.env.example +++ b/mvp/b2c/.env.example @@ -1,3 +1,9 @@ # Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C. # Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17 DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix" + +# Retell.ai — agente de voz saliente del funnel B2C. OPCIONALES: sin ellas la llamada no se +# dispara y el pipeline sigue en modo simulado. El agente se crea a mano en el panel de Retell. +RETELL_API_KEY="" +RETELL_AGENT_ID="" +RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000 diff --git a/mvp/b2c/src/lib/env.ts b/mvp/b2c/src/lib/env.ts new file mode 100644 index 0000000..8fd365b --- /dev/null +++ b/mvp/b2c/src/lib/env.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +// Variables de entorno de integraciones externas, validadas con zod (lo exige CLAUDE.md). +// Son OPCIONALES a propósito: sin ellas el funnel sigue en modo simulado, y ni el build ni +// la demo dependen de tener claves cargadas. +const opcional = z.preprocess( + (v) => (v === '' || v === undefined ? undefined : v), + z.string().min(1).optional(), +); + +const schema = z.object({ + RETELL_API_KEY: opcional, + RETELL_AGENT_ID: opcional, + RETELL_FROM_NUMBER: opcional, +}); + +export const env = schema.parse({ + RETELL_API_KEY: process.env.RETELL_API_KEY, + RETELL_AGENT_ID: process.env.RETELL_AGENT_ID, + RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER, +}); + +// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede +// ir ligado al número en el panel; si además se define RETELL_AGENT_ID, se manda como +// override_agent_id en cada llamada. +export function retellConfigurado(): boolean { + return Boolean(env.RETELL_API_KEY && env.RETELL_FROM_NUMBER); +} diff --git a/mvp/b2c/src/lib/funnel/orchestrator.ts b/mvp/b2c/src/lib/funnel/orchestrator.ts index de84f4e..cd7c198 100644 --- a/mvp/b2c/src/lib/funnel/orchestrator.ts +++ b/mvp/b2c/src/lib/funnel/orchestrator.ts @@ -1,11 +1,12 @@ import { eq } from 'drizzle-orm'; import { db } from '@/db'; -import { leads, leadPipelineEventos } from '@/db/schema'; +import { leads, leadPipelineEventos, tenants } from '@/db/schema'; import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries'; import { computeBudget } from '@/budget'; import type { Lead } from '@/db/schema'; import { deterministicExtractor } from '@/lib/voice/extractor'; import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply'; +import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell'; import type { RawCallData } from '@/lib/voice/preferences'; // Render demo por tipo de reforma. No hay generación IA real en esta fase (keyless): @@ -60,6 +61,13 @@ export async function procesarLead(leadId: string): Promise { const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); if (!lead) throw new Error('Lead no encontrado.'); + const [tenant] = await db + .select({ nombreEmpresa: tenants.nombreEmpresa }) + .from(tenants) + .where(eq(tenants.id, lead.tenantId)) + .limit(1); + const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix'; + const tipo = lead.tipoReforma ?? 'otro'; // Paso 4: pre-llamada (SMS + WhatsApp) @@ -69,13 +77,24 @@ export async function procesarLead(leadId: string): Promise { metadata: { via: ['sms', 'whatsapp'], simulado: true }, }); - // Paso 5: llamada del agente IA completada + // Paso 5: llamada del agente IA. Si Retell está configurado se lanza una llamada saliente + // REAL (el móvil del lead suena y el agente habla con sus variables); el render y el + // presupuesto se siguen generando con los datos del formulario (Arquitectura A de la demo). + const llamada = await iniciarLlamadaSaliente({ + telefono: lead.telefono, + variables: construirVariablesLlamada({ nombreEmpresa }, lead), + }); const transcripcion = construirTranscripcion(lead); const entidades = construirEntidades(lead); await db.insert(leadPipelineEventos).values({ leadId, stage: 'llamada_completada', - metadata: { simulado: true, duracionSeg: 95 }, + metadata: { + simulado: !llamada, + real: Boolean(llamada), + retellCallId: llamada?.callId, + duracionSeg: 95, + }, }); // Paso 6a: render IA generado diff --git a/mvp/b2c/src/lib/voice/retell.ts b/mvp/b2c/src/lib/voice/retell.ts new file mode 100644 index 0000000..d5ec782 --- /dev/null +++ b/mvp/b2c/src/lib/voice/retell.ts @@ -0,0 +1,77 @@ +import { env, retellConfigurado } from '@/lib/env'; +import { TIPO_LABEL } from '@/lib/funnel'; +import type { Lead } from '@/db/schema'; + +const CREATE_PHONE_CALL_URL = 'https://api.retellai.com/v2/create-phone-call'; + +// Normaliza un teléfono español a E.164 (+34XXXXXXXXX), que es lo que exige Retell. +// Acepta espacios, guiones, paréntesis y los prefijos +34 / 0034 / 34 ya puestos. +export function normalizarTelefonoEs(raw: string): string | null { + const limpio = raw.replace(/[\s\-().]/g, ''); + if (/^\+[1-9]\d{7,14}$/.test(limpio)) return limpio; + if (/^0034\d{9}$/.test(limpio)) return `+${limpio.slice(2)}`; + if (/^34\d{9}$/.test(limpio)) return `+${limpio}`; + if (/^[6789]\d{8}$/.test(limpio)) return `+34${limpio}`; + return null; +} + +export type VariablesLlamada = Record; + +// Dynamic variables que el agente de Retell inserta en su prompt ({{empresa_nombre}}, etc.). +// Solo los datos que el script "habla": empresa y nombre de pila del cliente; tipo de reforma +// y provincia van como contexto opcional para que el agente confirme en vez de preguntar a frío. +export function construirVariablesLlamada( + empresa: { nombreEmpresa: string }, + lead: Pick, +): VariablesLlamada { + const vars: VariablesLlamada = { + empresa_nombre: empresa.nombreEmpresa, + cliente_nombre: lead.nombre.split(' ')[0], + }; + if (lead.tipoReforma) vars.tipo_reforma = TIPO_LABEL[lead.tipoReforma].toLowerCase(); + if (lead.provincia) vars.provincia = lead.provincia; + return vars; +} + +export type ResultadoLlamada = { callId: string }; + +// Inicia una llamada saliente con el agente de Retell. Best-effort para la demo (Arquitectura A): +// el POST vuelve en cuanto Retell acepta la llamada (el móvil del lead empieza a sonar) y el +// pipeline continúa generando el entregable. Si Retell no está configurado, el teléfono no es +// válido o la API falla, devuelve null y NO lanza — el funnel sigue en modo simulado. +export async function iniciarLlamadaSaliente(opts: { + telefono: string; + variables: VariablesLlamada; +}): Promise { + if (!retellConfigurado()) return null; + + const toNumber = normalizarTelefonoEs(opts.telefono); + if (!toNumber) return null; + + const body: Record = { + from_number: env.RETELL_FROM_NUMBER, + to_number: toNumber, + retell_llm_dynamic_variables: opts.variables, + }; + if (env.RETELL_AGENT_ID) body.override_agent_id = env.RETELL_AGENT_ID; + + try { + const res = await fetch(CREATE_PHONE_CALL_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${env.RETELL_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + console.error(`Retell create-phone-call ${res.status}: ${await res.text()}`); + return null; + } + const data = (await res.json()) as { call_id?: string }; + return data.call_id ? { callId: data.call_id } : null; + } catch (err) { + console.error('Retell create-phone-call error:', err); + return null; + } +} diff --git a/mvp/b2c/tests/voice/retell.test.ts b/mvp/b2c/tests/voice/retell.test.ts new file mode 100644 index 0000000..034b3ee --- /dev/null +++ b/mvp/b2c/tests/voice/retell.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { normalizarTelefonoEs, construirVariablesLlamada } from '@/lib/voice/retell'; + +describe('normalizarTelefonoEs', () => { + it('añade prefijo +34 a un móvil nacional de 9 dígitos', () => { + expect(normalizarTelefonoEs('612345678')).toBe('+34612345678'); + }); + + it('acepta fijos nacionales (empiezan por 8/9)', () => { + expect(normalizarTelefonoEs('912345678')).toBe('+34912345678'); + }); + + it('limpia espacios, guiones y paréntesis', () => { + expect(normalizarTelefonoEs('612 34 56 78')).toBe('+34612345678'); + expect(normalizarTelefonoEs('+34 612-345-678')).toBe('+34612345678'); + }); + + it('respeta un E.164 ya formado', () => { + expect(normalizarTelefonoEs('+34612345678')).toBe('+34612345678'); + }); + + it('normaliza los prefijos 0034 y 34 sin +', () => { + expect(normalizarTelefonoEs('0034612345678')).toBe('+34612345678'); + expect(normalizarTelefonoEs('34612345678')).toBe('+34612345678'); + }); + + it('devuelve null si no es un teléfono válido', () => { + expect(normalizarTelefonoEs('123')).toBeNull(); + expect(normalizarTelefonoEs('hola')).toBeNull(); + }); +}); + +describe('construirVariablesLlamada', () => { + it('incluye siempre empresa_nombre y el nombre de pila del cliente', () => { + const vars = construirVariablesLlamada( + { nombreEmpresa: 'Reformas Ejemplo' }, + { nombre: 'Ana Pérez', tipoReforma: 'cocina', provincia: 'Madrid' }, + ); + expect(vars.empresa_nombre).toBe('Reformas Ejemplo'); + expect(vars.cliente_nombre).toBe('Ana'); + expect(vars.tipo_reforma).toBe('cocina'); + expect(vars.provincia).toBe('Madrid'); + }); + + it('omite tipo_reforma y provincia cuando faltan', () => { + const vars = construirVariablesLlamada( + { nombreEmpresa: 'Reformas Ejemplo' }, + { nombre: 'Luis', tipoReforma: null, provincia: null }, + ); + expect(vars.empresa_nombre).toBe('Reformas Ejemplo'); + expect(vars.cliente_nombre).toBe('Luis'); + expect('tipo_reforma' in vars).toBe(false); + expect('provincia' in vars).toBe(false); + }); + + it('usa la etiqueta en minúscula para tipo_reforma', () => { + const vars = construirVariablesLlamada( + { nombreEmpresa: 'X' }, + { nombre: 'Eva', tipoReforma: 'integral', provincia: null }, + ); + expect(vars.tipo_reforma).toBe('reforma integral'); + }); +});