diff --git a/mvp/b2c/src/app/solicitud/[id]/estado/page.tsx b/mvp/b2c/src/app/solicitud/[id]/estado/page.tsx new file mode 100644 index 0000000..e592ed4 --- /dev/null +++ b/mvp/b2c/src/app/solicitud/[id]/estado/page.tsx @@ -0,0 +1,133 @@ +import { notFound } from 'next/navigation'; +import { getPublicLead } from '@/lib/funnel/public-queries'; +import { PIPELINE_ORDER, PIPELINE_LABEL, TIPO_LABEL, formatEuros } from '@/lib/funnel'; +import type { BudgetResult } from '@/budget/types'; + +export const dynamic = 'force-dynamic'; + +export default async function EstadoPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const data = await getPublicLead(id); + if (!data) notFound(); + + const { lead, eventos } = data; + const reachedStages = new Set(eventos.map((e) => e.stage)); + + const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null; + const desglose = snapshot?.result ?? null; + const entregado = lead.pipelineStage === 'whatsapp_entregado'; + const tipo = lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'tu reforma'; + + return ( +
+ {/* Cabecera de éxito */} +
+
+ +
+

+ ¡Tu presupuesto está listo, {lead.nombre.split(' ')[0]}! +

+

+ {entregado + ? 'Te lo hemos enviado por WhatsApp. Aquí tienes un adelanto del render y el presupuesto orientativo de tu reforma.' + : 'Estamos preparando tu render y presupuesto. En breve lo recibirás por WhatsApp.'} +

+
+ + {/* Render */} + {lead.renderUrl && ( +
+
+

+ Render de {tipo.toLowerCase()} +

+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Render de tu reforma +
+ )} + + {/* Presupuesto */} + {desglose ? ( +
+
+
+
+ Presupuesto orientativo +
+
{formatEuros(desglose.total)}
+
+
+
Rango estimado
+ {formatEuros(desglose.rango.min)} – {formatEuros(desglose.rango.max)} +
+
+ + + + {desglose.avisos.length > 0 && ( + + )} + +

+ Presupuesto orientativo. El precio final puede variar según la visita técnica. +

+
+ ) : ( +
+ Calculando tu presupuesto… +
+ )} + + {/* Progreso del pipeline */} +
+

+ Estado de tu solicitud +

+
    + {PIPELINE_ORDER.map((stage) => { + const reached = reachedStages.has(stage); + return ( +
  1. + + + {PIPELINE_LABEL[stage]} + +
  2. + ); + })} +
+
+ + {entregado && ( +
+ Presupuesto enviado a tu WhatsApp ✓ +
+ )} +
+ ); +} diff --git a/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx b/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx new file mode 100644 index 0000000..47b45d6 --- /dev/null +++ b/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx @@ -0,0 +1,35 @@ +import { notFound } from 'next/navigation'; +import { getPublicLead } from '@/lib/funnel/public-queries'; +import { guardarDetallesYFotos } from '../../actions'; +import FotosUploader from '@/components/funnel/FotosUploader'; + +export const dynamic = 'force-dynamic'; + +export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const data = await getPublicLead(id); + if (!data) notFound(); + + const { lead } = data; + + return ( +
+
+ + Paso 2 de 2 + +

+ Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma +

+

+ Sube unas fotos del espacio y dinos qué tienes en mente. Con eso preparamos tu render y un + presupuesto orientativo en menos de un minuto. +

+
+ +
+ +
+
+ ); +} diff --git a/mvp/b2c/src/app/solicitud/actions.ts b/mvp/b2c/src/app/solicitud/actions.ts new file mode 100644 index 0000000..7060dcb --- /dev/null +++ b/mvp/b2c/src/app/solicitud/actions.ts @@ -0,0 +1,140 @@ +'use server'; + +import { z } from 'zod'; +import { and, eq } from 'drizzle-orm'; +import { redirect } from 'next/navigation'; +import { revalidatePath } from 'next/cache'; +import { db } from '@/db'; +import { leads, leadFotos, leadPipelineEventos } from '@/db/schema'; +import { getDemoTenantId } from '@/lib/funnel/public-queries'; +import { procesarLead } from '@/lib/funnel/orchestrator'; + +const MAX_FOTOS = 4; +const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto + +const crearLeadSchema = z.object({ + nombre: z.string().trim().min(2, 'El nombre es obligatorio'), + email: z.string().trim().email('Introduce un email válido'), + telefono: z + .string() + .trim() + .regex(/^[+\d\s\-().]{7,20}$/, 'Introduce un teléfono válido'), + consentPrivacidad: z.boolean(), + consentContratacion: z.boolean(), +}); + +export type CrearLeadInput = z.input; +export type CrearLeadResult = + | { ok: true; leadId: string } + | { ok: false; error: string }; + +export async function crearLead(input: CrearLeadInput): Promise { + const parsed = crearLeadSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' }; + } + const data = parsed.data; + + // RF-LEG-01: los dos consentimientos son obligatorios para iniciar el funnel. + if (!data.consentPrivacidad || !data.consentContratacion) { + return { ok: false, error: 'Debes aceptar la política de privacidad y las condiciones.' }; + } + + const tenantId = await getDemoTenantId(); + + const [lead] = await db + .insert(leads) + .values({ + tenantId, + nombre: data.nombre, + email: data.email, + telefono: data.telefono, + consentPrivacidad: data.consentPrivacidad, + consentContratacion: data.consentContratacion, + pipelineStage: 'form_completado', + estado: 'nuevo', + }) + .returning({ id: leads.id }); + + await db.insert(leadPipelineEventos).values({ + leadId: lead.id, + stage: 'form_completado', + metadata: { origen: 'landing' }, + }); + + return { ok: true, leadId: lead.id }; +} + +const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const; +const CALIDADES = ['basica', 'media', 'premium'] as const; + +async function fileToDataUri(file: File): Promise { + if (file.size === 0 || file.size > MAX_FOTO_BYTES) return null; + if (!file.type.startsWith('image/')) return null; + const buffer = Buffer.from(await file.arrayBuffer()); + return `data:${file.type};base64,${buffer.toString('base64')}`; +} + +// Paso 3 del funnel: el cliente sube fotos y confirma los datos clave de la reforma. +// Guardamos las fotos como data URI (no hay storage externo en esta fase) y disparamos +// el orquestador que simula la llamada/render y calcula el presupuesto real. +export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise { + const tenantId = await getDemoTenantId(); + + const [lead] = await db + .select() + .from(leads) + .where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId))) + .limit(1); + if (!lead) throw new Error('Solicitud no encontrada.'); + + const tipoRaw = String(formData.get('tipoReforma') ?? ''); + const calidadRaw = String(formData.get('calidad') ?? ''); + const m2Raw = Number(formData.get('m2')); + const provincia = String(formData.get('provincia') ?? '').trim() || null; + + const tipoReforma = (TIPOS as readonly string[]).includes(tipoRaw) + ? (tipoRaw as (typeof TIPOS)[number]) + : 'otro'; + const calidadGlobal = (CALIDADES as readonly string[]).includes(calidadRaw) + ? (calidadRaw as (typeof CALIDADES)[number]) + : 'media'; + const m2Suelo = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null; + + const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File); + const dataUris: string[] = []; + for (const file of archivos.slice(0, MAX_FOTOS)) { + const uri = await fileToDataUri(file); + if (uri) dataUris.push(uri); + } + + if (dataUris.length > 0) { + await db.insert(leadFotos).values( + dataUris.map((url, orden) => ({ leadId, url, orden })) + ); + } + + await db + .update(leads) + .set({ + tipoReforma, + calidadGlobal, + m2Suelo, + provincia, + pipelineStage: 'fotos_subidas', + updatedAt: new Date(), + }) + .where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId))); + + await db.insert(leadPipelineEventos).values({ + leadId, + stage: 'fotos_subidas', + metadata: { fotos: dataUris.length }, + }); + + // Dispara el resto del pipeline (llamada simulada → render → presupuesto real → WhatsApp). + await procesarLead(leadId); + + revalidatePath('/panel'); + redirect(`/solicitud/${leadId}/estado`); +} diff --git a/mvp/b2c/src/app/solicitud/layout.tsx b/mvp/b2c/src/app/solicitud/layout.tsx new file mode 100644 index 0000000..f65198e --- /dev/null +++ b/mvp/b2c/src/app/solicitud/layout.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; + +export default function SolicitudLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + Reformix + + + Tu presupuesto + +
+
+
+
{children}
+
+
+
+ Reformix · Presupuesto orientativo. El precio final puede variar según la visita técnica. +
+
+
+ ); +} diff --git a/mvp/b2c/src/components/ContactForm/ContactForm.tsx b/mvp/b2c/src/components/ContactForm/ContactForm.tsx index cc73a66..1cbf168 100644 --- a/mvp/b2c/src/components/ContactForm/ContactForm.tsx +++ b/mvp/b2c/src/components/ContactForm/ContactForm.tsx @@ -1,6 +1,8 @@ 'use client'; import { useState, useRef, useEffect, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { crearLead } from '@/app/solicitud/actions'; type FormData = { name: string; @@ -44,6 +46,8 @@ export default function ContactForm() { const [errors, setErrors] = useState({}); const [touched, setTouched] = useState>>({}); const [status, setStatus] = useState('idle'); + const [submitError, setSubmitError] = useState(null); + const router = useRouter(); const sectionRef = useRef(null); useEffect(() => { @@ -94,13 +98,20 @@ export default function ContactForm() { if (!consentsGranted) return; setStatus('loading'); - // TODO: integrar con backend captación (lead -> pending_photos). De momento mock. - await new Promise((resolve) => setTimeout(resolve, 1500)); - setStatus('success'); - setFormData(initialData); - setConsents(initialConsents); - setTouched({}); - setErrors({}); + setSubmitError(null); + const result = await crearLead({ + nombre: formData.name, + email: formData.email, + telefono: formData.phone, + consentPrivacidad: consents.privacy, + consentContratacion: consents.contracting, + }); + if (!result.ok) { + setStatus('error'); + setSubmitError(result.error); + return; + } + router.push(`/solicitud/${result.leadId}/fotos`); }; const handleReset = () => { @@ -405,6 +416,12 @@ export default function ContactForm() { + {submitError && ( +

+ {submitError} +

+ )} + {/* Submit */} + ); +} + +export default function FotosUploader({ + action, +}: { + action: (formData: FormData) => void | Promise; +}) { + const [previews, setPreviews] = useState([]); + + const handleFiles = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []).slice(0, MAX_FOTOS); + previews.forEach((url) => URL.revokeObjectURL(url)); + setPreviews(files.map((f) => URL.createObjectURL(f))); + }; + + return ( +
+ {/* Fotos */} +
+ + + {previews.length > 0 && ( +
+ {previews.map((url, i) => ( + // eslint-disable-next-line @next/next/no-img-element + + ))} +
+ )} +
+ + {/* Tipo de reforma */} +
+ + +
+ + {/* m2 + calidad */} +
+
+ + +
+
+ + +
+
+ + {/* Provincia */} +
+ + +
+ + +

+ Calculamos un presupuesto orientativo con tus datos. Sin compromiso. +

+ + ); +} diff --git a/mvp/b2c/src/db/pricing-queries.ts b/mvp/b2c/src/db/pricing-queries.ts index 9efa021..826b236 100644 --- a/mvp/b2c/src/db/pricing-queries.ts +++ b/mvp/b2c/src/db/pricing-queries.ts @@ -6,8 +6,7 @@ import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user'; export type EnvioMode = (typeof tenants.envioPresupuesto.enumValues)[number]; -export async function getEnvioMode(): Promise { - const tenantId = await getTenantId(); +export async function getEnvioModeFor(tenantId: string): Promise { const [row] = await db .select({ modo: tenants.envioPresupuesto }) .from(tenants) @@ -16,6 +15,10 @@ export async function getEnvioMode(): Promise { return row?.modo ?? 'automatico'; } +export async function getEnvioMode(): Promise { + return getEnvioModeFor(await getTenantId()); +} + const MANO_OBRA_DEFAULT: Record = { demolicion: 0, fontaneria: 0, @@ -23,8 +26,7 @@ const MANO_OBRA_DEFAULT: Record = { mano_de_obra: 0, }; -export async function getPricingConfig(): Promise { - const tenantId = await getTenantId(); +export async function getPricingConfigFor(tenantId: string): Promise { const [row] = await db .select() .from(pricingConfig) @@ -41,8 +43,11 @@ export async function getPricingConfig(): Promise { }; } -export async function getCatalog(): Promise { - const tenantId = await getTenantId(); +export async function getPricingConfig(): Promise { + return getPricingConfigFor(await getTenantId()); +} + +export async function getCatalogFor(tenantId: string): Promise { const rows = await db.select().from(catalogItems).where(eq(catalogItems.tenantId, tenantId)); return rows.map((r) => ({ id: r.id, @@ -57,4 +62,8 @@ export async function getCatalog(): Promise { })); } +export async function getCatalog(): Promise { + return getCatalogFor(await getTenantId()); +} + export { getTenantId }; diff --git a/mvp/b2c/src/lib/funnel/orchestrator.ts b/mvp/b2c/src/lib/funnel/orchestrator.ts new file mode 100644 index 0000000..0321516 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/orchestrator.ts @@ -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, 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, 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 { + 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) ?? {}, + }; + 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 }; diff --git a/mvp/b2c/src/lib/funnel/public-queries.ts b/mvp/b2c/src/lib/funnel/public-queries.ts new file mode 100644 index 0000000..1081185 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/public-queries.ts @@ -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 { + 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 }; +}