Conectar funnel B2C real sin claves: captura → fotos → presupuesto

El formulario de la landing ahora crea un lead real en BD y redirige a
/solicitud/[id]/fotos, donde el cliente sube fotos y datos de la reforma.
El orquestador simula los pasos de IA (pre-llamada, llamada, render) y
calcula el presupuesto DE VERDAD con el catálogo del reformista, dejando
el lead listo en el panel con render y desglose.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-31 14:29:21 +02:00
parent b95c588efe
commit b582f3ac33
10 changed files with 733 additions and 19 deletions

View File

@@ -0,0 +1,141 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadPipelineEventos } from '@/db/schema';
import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import type { BudgetInputs } from '@/budget/types';
import type { Lead } from '@/db/schema';
// Render demo por tipo de reforma. No hay generación IA real en esta fase (keyless):
// reusamos los renders de muestra del directorio público.
const RENDER_POR_TIPO: Record<NonNullable<Lead['tipoReforma']>, string> = {
cocina: '/despues.webp',
bano: '/despues-bano.webp',
salon: '/despues-comedor.webp',
comedor: '/despues-comedor.webp',
integral: '/despues.webp',
otro: '/despues.webp',
};
const TIPO_TEXTO: Record<NonNullable<Lead['tipoReforma']>, string> = {
cocina: 'la cocina',
bano: 'el baño',
salon: 'el salón',
comedor: 'el comedor',
integral: 'el piso entero',
otro: 'la reforma',
};
function construirTranscripcion(lead: Lead): string {
const tipo = lead.tipoReforma ?? 'otro';
const m2 = lead.m2Suelo ? `${Math.round(lead.m2Suelo)} metros` : 'el espacio';
const calidad = lead.calidadGlobal ?? 'media';
return [
`Agente: Hola ${lead.nombre.split(' ')[0]}, te llamo de Reformas Ejemplo. Te aviso de que soy un asistente con inteligencia artificial y de que la llamada queda grabada. ¿Tienes un momento?`,
`Cliente: Sí, sin problema.`,
`Agente: Perfecto. Me comentas que quieres reformar ${TIPO_TEXTO[tipo]}, ¿unos ${m2} aproximadamente?`,
`Cliente: Eso es, ${m2}. Quiero un acabado de calidad ${calidad}.`,
`Agente: Genial. Con las fotos que has subido y estos datos te preparo ahora mismo el render y el presupuesto orientativo, y te lo enviamos por WhatsApp. Muchas gracias.`,
].join('\n');
}
function construirEntidades(lead: Lead) {
return {
espacio: lead.tipoReforma ?? 'otro',
m2_aprox: lead.m2Suelo ?? null,
calidad: lead.calidadGlobal ?? 'media',
provincia: lead.provincia ?? null,
estructural: lead.estructural,
};
}
// Avanza un lead recién capturado por todo el pipeline B2C.
// Los pasos de IA (pre-llamada, llamada, render) se simulan de forma realista;
// el presupuesto se calcula DE VERDAD con el motor y el catálogo del reformista.
// Respeta la preferencia de envío del reformista: 'automatico' entrega por WhatsApp,
// 'revision' se detiene en 'presupuesto_generado' para que el reformista lo revise.
export async function procesarLead(leadId: string): Promise<void> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const tipo = lead.tipoReforma ?? 'otro';
// Paso 4: pre-llamada (SMS + WhatsApp)
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'prellamada_enviada',
metadata: { via: ['sms', 'whatsapp'], simulado: true },
});
// Paso 5: llamada del agente IA completada
const transcripcion = construirTranscripcion(lead);
const entidades = construirEntidades(lead);
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'llamada_completada',
metadata: { simulado: true, duracionSeg: 95 },
});
// Paso 6a: render IA generado
const renderUrl = RENDER_POR_TIPO[tipo];
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'render_generado',
metadata: { simulado: true, renderUrl },
});
// Paso 6b: presupuesto calculado (REAL) con el catálogo del reformista
const [config, catalog] = await Promise.all([
getPricingConfigFor(lead.tenantId),
getCatalogFor(lead.tenantId),
]);
const inputs: BudgetInputs = {
tipoReforma: tipo,
m2Suelo: lead.m2Suelo ?? null,
alturaTecho: lead.alturaTecho ?? null,
calidadGlobal: lead.calidadGlobal ?? 'media',
estructural: lead.estructural,
provincia: lead.provincia ?? null,
materialSelections: (lead.materialSelections as Record<string, string>) ?? {},
};
const result = computeBudget(inputs, config, catalog);
await db
.update(leads)
.set({
transcripcion,
entidades,
renderUrl,
presupuestoEstimado: result.total,
desgloseSnapshot: { stage: 'presupuesto_generado', result },
pipelineStage: 'presupuesto_generado',
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'presupuesto_generado',
metadata: { total: result.total, confianza: result.confianza },
});
// Paso 7: entrega por WhatsApp si el reformista tiene envío automático.
const envio = await getEnvioModeFor(lead.tenantId);
if (envio === 'automatico') {
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: 'whatsapp', simulado: true, total: result.total },
});
}
}
export { RENDER_POR_TIPO };

View File

@@ -0,0 +1,42 @@
import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { tenants, leads, leadFotos, leadPipelineEventos } from '@/db/schema';
import { TENANT_SLUG } from '@/lib/funnel';
// Tenant demo del MVP ("Reformas Ejemplo"). El funnel público es anónimo:
// todos los leads se atribuyen a este reformista hasta que multi-tenant B2C (F1.5) exista.
export async function getDemoTenantId(): Promise<string> {
const [row] = await db
.select({ id: tenants.id })
.from(tenants)
.where(eq(tenants.slug, TENANT_SLUG))
.limit(1);
if (!row) {
throw new Error(`Tenant demo "${TENANT_SLUG}" no encontrado. Ejecuta el seed de la base de datos.`);
}
return row.id;
}
// Lectura del lead para las páginas públicas del funnel. Scoped al tenant demo,
// nunca expone leads de otros reformistas.
export async function getPublicLead(id: string) {
const tenantId = await getDemoTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, id), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) return null;
const [fotos, eventos] = await Promise.all([
db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)),
db
.select()
.from(leadPipelineEventos)
.where(eq(leadPipelineEventos.leadId, id))
.orderBy(asc(leadPipelineEventos.occurredAt)),
]);
return { lead, fotos, eventos };
}