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:
141
mvp/b2c/src/lib/funnel/orchestrator.ts
Normal file
141
mvp/b2c/src/lib/funnel/orchestrator.ts
Normal 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 };
|
||||
42
mvp/b2c/src/lib/funnel/public-queries.ts
Normal file
42
mvp/b2c/src/lib/funnel/public-queries.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user