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:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user