Añade canales de llamada y WhatsApp al funnel B2C

- Llamada (/llamada): "Llamar ahora" dispara la llamada saliente de Retell;
  "Programar" registra fecha/hora. Tras pedir, ofrece enviar por email el
  enlace al formulario para subir las imágenes.
- WhatsApp (/whatsapp): arranca la conversación vía webhook al flujo externo
  (con el teléfono del lead) y pide confirmación; al confirmar, agradece y
  sigue por WhatsApp.
- actions: pedirLlamada, enviarEnlaceFormularioEmail, iniciarWhatsapp,
  confirmarWhatsapp.

Verificado en navegador: las 3 pantallas y sus transiciones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:20:14 +02:00
parent 9b5b0d59a6
commit 0a5f8cba2b
5 changed files with 350 additions and 0 deletions

View File

@@ -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<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 };
}