From 0a5f8cba2be6c7bcabfd7c9a5fa41a9652851fc4 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 3 Jun 2026 19:20:14 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20canales=20de=20llamada=20y=20Whats?= =?UTF-8?q?App=20al=20funnel=20B2C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Llamada (/llamada): "Llamar ahora" dispara la llamada saliente de Retell; "Programar" registra fecha/hora. Tras pedir, ofrece enviar por email el enlace al formulario para subir las imágenes. - WhatsApp (/whatsapp): arranca la conversación vía webhook al flujo externo (con el teléfono del lead) y pide confirmación; al confirmar, agradece y sigue por WhatsApp. - actions: pedirLlamada, enviarEnlaceFormularioEmail, iniciarWhatsapp, confirmarWhatsapp. Verificado en navegador: las 3 pantallas y sus transiciones. Co-Authored-By: Claude Opus 4.8 --- .../src/app/solicitud/[id]/llamada/page.tsx | 38 ++++++ .../src/app/solicitud/[id]/whatsapp/page.tsx | 32 +++++ mvp/b2c/src/app/solicitud/actions.ts | 80 +++++++++++ .../src/components/funnel/CanalLlamada.tsx | 124 ++++++++++++++++++ .../src/components/funnel/CanalWhatsapp.tsx | 76 +++++++++++ 5 files changed, 350 insertions(+) create mode 100644 mvp/b2c/src/app/solicitud/[id]/llamada/page.tsx create mode 100644 mvp/b2c/src/app/solicitud/[id]/whatsapp/page.tsx create mode 100644 mvp/b2c/src/components/funnel/CanalLlamada.tsx create mode 100644 mvp/b2c/src/components/funnel/CanalWhatsapp.tsx diff --git a/mvp/b2c/src/app/solicitud/[id]/llamada/page.tsx b/mvp/b2c/src/app/solicitud/[id]/llamada/page.tsx new file mode 100644 index 0000000..567c203 --- /dev/null +++ b/mvp/b2c/src/app/solicitud/[id]/llamada/page.tsx @@ -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 && } +
+
+ + Te llamamos + +

+ Te llamamos cuando quieras, {lead.nombre.split(' ')[0]} +

+

+ Un asistente de {tenant?.nombreEmpresa ?? 'la empresa'} te llama y te hace unas preguntas + rápidas sobre tu reforma. Te avisamos antes. +

+
+ +
+ +
+
+ + ); +} diff --git a/mvp/b2c/src/app/solicitud/[id]/whatsapp/page.tsx b/mvp/b2c/src/app/solicitud/[id]/whatsapp/page.tsx new file mode 100644 index 0000000..26dec71 --- /dev/null +++ b/mvp/b2c/src/app/solicitud/[id]/whatsapp/page.tsx @@ -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 && } +
+
+ + Por WhatsApp + +

Seguimos por WhatsApp

+
+ +
+ +
+
+ + ); +} diff --git a/mvp/b2c/src/app/solicitud/actions.ts b/mvp/b2c/src/app/solicitud/actions.ts index 2a4465a..aea15bb 100644 --- a/mvp/b2c/src/app/solicitud/actions.ts +++ b/mvp/b2c/src/app/solicitud/actions.ts @@ -8,8 +8,13 @@ import { db } from '@/db'; import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema'; import type { NewLeadFoto, NewLeadNota } from '@/db/schema'; import { getTenantBySlug } from '@/lib/funnel/public-queries'; +import { getTenantPerfilById } from '@/db/tenant-queries'; import { procesarLead } from '@/lib/funnel/orchestrator'; import { señalarPerfilCompleto } from '@/lib/funnel/perfil'; +import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell'; +import { iniciarConversacionWhatsapp } from '@/lib/webhooks'; +import { enviarEnlaceFormulario } from '@/lib/email/mailer'; +import { env } from '@/lib/env'; const MAX_ZONAS = 6; const MAX_FOTOS_ZONA = 6; @@ -207,3 +212,78 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData): revalidatePath('/panel'); redirect(`/solicitud/${leadId}/estado`); } + +// Canal llamada: el cliente pide que le llamen ahora o programa la llamada. "Ahora" dispara la +// llamada saliente de Retell; "programar" registra la fecha y la señala (el dialing en hora lo +// hace el flujo externo, la app no monta cron). Best-effort. +export async function pedirLlamada( + leadId: string, + cuando: 'ahora' | string, +): Promise<{ ok: boolean; programada?: string }> { + const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); + if (!lead) return { ok: false }; + const tenant = await getTenantPerfilById(lead.tenantId); + + if (cuando === 'ahora') { + const llamada = await iniciarLlamadaSaliente({ + telefono: lead.telefono, + variables: construirVariablesLlamada({ nombreEmpresa: tenant.nombreEmpresa }, lead), + }); + await db.insert(leadPipelineEventos).values({ + leadId, + stage: 'prellamada_enviada', + metadata: { via: 'llamada', cuando: 'ahora', real: Boolean(llamada), simulado: !llamada }, + }); + return { ok: true }; + } + + const fecha = new Date(cuando); + const programadaAt = Number.isNaN(fecha.getTime()) ? null : fecha.toISOString(); + await db.insert(leadPipelineEventos).values({ + leadId, + stage: 'prellamada_enviada', + metadata: { via: 'llamada', cuando: 'programada', programadaAt }, + }); + return { ok: true, programada: programadaAt ?? undefined }; +} + +// Canal llamada: envía al cliente un email con el enlace a su formulario para subir las imágenes. +export async function enviarEnlaceFormularioEmail(leadId: string): Promise { + 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 }; +} diff --git a/mvp/b2c/src/components/funnel/CanalLlamada.tsx b/mvp/b2c/src/components/funnel/CanalLlamada.tsx new file mode 100644 index 0000000..a6ca70a --- /dev/null +++ b/mvp/b2c/src/components/funnel/CanalLlamada.tsx @@ -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(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 ( +
+
+ ✅ {confirmacion} +
+
+

+ 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. +

+ {enlaceEnviado ? ( +
📧 Te hemos enviado el enlace a {email}.
+ ) : ( + + )} +
+
+ ); + } + + return ( +
+
+ Llamarme ahora + Recibes la llamada en menos de 2 minutos. + +
+ +
+ Programar la llamada + Elige el día y la hora que mejor te venga. + {mostrarProgramar ? ( +
+ 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" + /> + +
+ ) : ( + + )} +
+
+ ); +} diff --git a/mvp/b2c/src/components/funnel/CanalWhatsapp.tsx b/mvp/b2c/src/components/funnel/CanalWhatsapp.tsx new file mode 100644 index 0000000..daf74e3 --- /dev/null +++ b/mvp/b2c/src/components/funnel/CanalWhatsapp.tsx @@ -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('idle'); + + const escribir = () => + startTransition(async () => { + await iniciarWhatsapp(leadId); + setFase('escrito'); + }); + + const confirmar = () => + startTransition(async () => { + await confirmarWhatsapp(leadId); + setFase('confirmado'); + }); + + if (fase === 'confirmado') { + return ( +
+ ✅ ¡Genial, {nombre.split(' ')[0]}! Seguimos por WhatsApp. Allí te pediremos las fotos y los + detalles para preparar tu presupuesto. +
+ ); + } + + if (fase === 'escrito') { + return ( +
+

+ Te acabamos de escribir al {telefono}. ¿Puedes + confirmarlo? +

+ +
+ ); + } + + return ( +
+

+ Te escribimos al WhatsApp del {telefono} para seguir + por ahí. Si el número es correcto, confírmalo y te escribimos ahora mismo. +

+ +
+ ); +}