Añade chooser de canal y formulario por zonas al funnel B2C

- Paso intermedio /solicitud/[id]: el cliente elige llamada, WhatsApp o
  formulario (crearLead ahora redirige aquí, no a /fotos).
- /formulario: FormularioZonas permite añadir varias zonas, cada una con tipo,
  m², acabado, notas y fotos; /fotos queda como redirect.
- guardarDetallesYFotos: guarda fotos (antes, por zona) y notas (por zona),
  agrega los campos del lead (m² suma, tipo único o 'integral', calidad más
  alta, tasteText concatenado) para el presupuesto orientativo inmediato, y
  señala perfilCompleto al flujo externo.
- Elimina FotosUploader (sustituido por FormularioZonas).

Verificado en navegador: 2 zonas → presupuesto al instante + notas por zona +
evento de perfil en DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:17:11 +02:00
parent f87a3ecd81
commit 9b5b0d59a6
8 changed files with 499 additions and 274 deletions

View File

@@ -0,0 +1,39 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { guardarDetallesYFotos } from '../../actions';
import FormularioZonas from '@/components/funnel/FormularioZonas';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
export default async function FormularioPage({ 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">
Cuéntanos tu reforma
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Añade cada zona que quieras reformar con sus fotos y detalles. Con eso preparamos tu
render y un presupuesto orientativo en menos de un minuto.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<FormularioZonas action={guardarDetallesYFotos.bind(null, id)} />
</div>
</div>
</>
);
}

View File

@@ -1,39 +1,10 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { guardarDetallesYFotos } from '../../actions';
import FotosUploader from '@/components/funnel/FotosUploader';
import TenantBrand from '@/components/funnel/TenantBrand';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) {
// La subida de fotos vive ahora en /formulario (formulario por zonas). Mantenemos /fotos como
// redirect por compatibilidad con enlaces antiguos.
export default async function FotosRedirect({ 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">
Paso 2 de 2
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
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.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<FotosUploader action={guardarDetallesYFotos.bind(null, id)} />
</div>
</div>
</>
);
redirect(`/solicitud/${id}/formulario`);
}

View File

@@ -0,0 +1,82 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
const CANALES = [
{
slug: 'llamada',
icon: '📞',
titulo: 'Que te llamemos',
descripcion:
'Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.',
cta: 'Quiero que me llamen',
},
{
slug: 'whatsapp',
icon: '💬',
titulo: 'Por WhatsApp',
descripcion:
'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
cta: 'Seguir por WhatsApp',
},
{
slug: 'formulario',
icon: '📝',
titulo: 'Rellenar un formulario',
descripcion:
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
cta: 'Rellenar el formulario',
},
] as const;
export default async function ChooserPage({ 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">
Elige cómo seguir
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}?
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render
y tu presupuesto.
</p>
</div>
<div className="flex flex-col gap-3">
{CANALES.map((c) => (
<Link
key={c.slug}
href={`/solicitud/${id}/${c.slug}`}
className="group bg-white border border-gray-200 rounded-xl p-5 shadow-sm flex items-center gap-4 transition-all hover:border-black hover:shadow-md"
>
<span className="text-3xl shrink-0" aria-hidden="true">
{c.icon}
</span>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-base font-bold text-black">{c.titulo}</span>
<span className="text-sm text-gray-500 leading-snug">{c.descripcion}</span>
<span className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] mt-1">
{c.cta}
</span>
</div>
</Link>
))}
</div>
</div>
</>
);
}

View File

@@ -5,11 +5,14 @@ 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 { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
import { getTenantBySlug } from '@/lib/funnel/public-queries';
import { procesarLead } from '@/lib/funnel/orchestrator';
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
const MAX_FOTOS = 4;
const MAX_ZONAS = 6;
const MAX_FOTOS_ZONA = 6;
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
const crearLeadSchema = z.object({
@@ -79,27 +82,57 @@ async function fileToDataUri(file: File): Promise<string | null> {
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.
const CALIDAD_RANK: Record<(typeof CALIDADES)[number], number> = { basica: 0, media: 1, premium: 2 };
type ZonaParseada = {
tipo: (typeof TIPOS)[number];
m2: number | null;
calidad: (typeof CALIDADES)[number];
notas: string | null;
fotos: string[]; // data URIs
};
// Lee las zonas del FormData (campos zona-<i>-tipo / -m2 / -calidad / -notas / -fotos).
async function parsearZonas(formData: FormData): Promise<ZonaParseada[]> {
const count = Math.min(Number(formData.get('zonasCount')) || 0, MAX_ZONAS);
const zonas: ZonaParseada[] = [];
for (let i = 0; i < count; i++) {
const tipoRaw = String(formData.get(`zona-${i}-tipo`) ?? '');
const calidadRaw = String(formData.get(`zona-${i}-calidad`) ?? '');
const m2Raw = Number(formData.get(`zona-${i}-m2`));
const tipo = (TIPOS as readonly string[]).includes(tipoRaw)
? (tipoRaw as (typeof TIPOS)[number])
: 'otro';
const calidad = (CALIDADES as readonly string[]).includes(calidadRaw)
? (calidadRaw as (typeof CALIDADES)[number])
: 'media';
const m2 = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
const notas = String(formData.get(`zona-${i}-notas`) ?? '').trim() || null;
const archivos = formData
.getAll(`zona-${i}-fotos`)
.filter((f): f is File => f instanceof File)
.slice(0, MAX_FOTOS_ZONA);
const fotos: string[] = [];
for (const file of archivos) {
const uri = await fileToDataUri(file);
if (uri) fotos.push(uri);
}
zonas.push({ tipo, m2, calidad, notas, fotos });
}
return zonas;
}
// Paso 2 (canal formulario): el cliente describe la reforma zona por zona y sube fotos.
// Guardamos fotos (momento 'antes', etiquetadas por zona) y notas como data en lead_notas;
// agregamos los campos del lead para calcular el presupuesto orientativo al instante con el motor
// actual, y señalamos "perfil completo" al flujo externo para que genere los renders "después".
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) throw new Error('Solicitud no encontrada.');
const tenantId = lead.tenantId;
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 urgenciaRaw = String(formData.get('urgencia') ?? '');
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
? (urgenciaRaw as 'alta' | 'media' | 'baja')
@@ -108,20 +141,40 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
const presupuestoTarget =
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
const estructural = formData.get('estructural') === 'on';
const tasteText = String(formData.get('tasteText') ?? '').trim() || 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);
let zonas = await parsearZonas(formData);
if (zonas.length === 0) {
zonas = [{ tipo: 'otro', m2: null, calidad: 'media', notas: null, fotos: [] }];
}
if (dataUris.length > 0) {
await db.insert(leadFotos).values(
dataUris.map((url, orden) => ({ leadId, url, orden }))
);
// Inserta fotos (antes, por zona) y notas (por zona) en la estructura del lead.
const fotoRows: NewLeadFoto[] = [];
const notaRows: NewLeadNota[] = [];
let orden = 0;
for (const z of zonas) {
for (const url of z.fotos) {
fotoRows.push({ leadId, url, momento: 'antes', zona: z.tipo, orden: orden++ });
}
if (z.notas) notaRows.push({ leadId, texto: z.notas, zona: z.tipo, origen: 'funnel' });
}
if (fotoRows.length > 0) await db.insert(leadFotos).values(fotoRows);
if (notaRows.length > 0) await db.insert(leadNotas).values(notaRows);
// Agregado para el motor de presupuesto (multi-zona "de verdad" = F1.5): m² suma, tipo único
// o 'integral' si hay varias zonas, calidad la más alta, y tasteText con las notas concatenadas.
const tiposUnicos = Array.from(new Set(zonas.map((z) => z.tipo)));
const tipoReforma = tiposUnicos.length === 1 ? tiposUnicos[0] : 'integral';
const m2Total = zonas.reduce((s, z) => s + (z.m2 ?? 0), 0);
const m2Suelo = m2Total > 0 ? m2Total : null;
const calidadGlobal = zonas.reduce<(typeof CALIDADES)[number]>(
(best, z) => (CALIDAD_RANK[z.calidad] > CALIDAD_RANK[best] ? z.calidad : best),
'basica',
);
const tasteText =
zonas
.filter((z) => z.notas)
.map((z) => `${z.tipo}: ${z.notas}`)
.join('\n') || null;
await db
.update(leads)
@@ -142,12 +195,15 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'fotos_subidas',
metadata: { fotos: dataUris.length },
metadata: { fotos: fotoRows.length, notas: notaRows.length, zonas: zonas.length },
});
// Dispara el resto del pipeline (llamada simulada → render → presupuesto real → WhatsApp).
// Presupuesto orientativo inmediato (motor actual). La rama de WhatsApp queda simulada.
await procesarLead(leadId);
// Señala al flujo externo que el perfil está listo para generar los renders "después".
await señalarPerfilCompleto(leadId);
revalidatePath('/panel');
redirect(`/solicitud/${leadId}/estado`);
}