From 9b5b0d59a62ba4f0be5c4903905294e9fa4bc1e6 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 3 Jun 2026 19:17:11 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20chooser=20de=20canal=20y=20formula?= =?UTF-8?q?rio=20por=20zonas=20al=20funnel=20B2C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../app/solicitud/[id]/formulario/page.tsx | 39 +++ mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx | 39 +-- mvp/b2c/src/app/solicitud/[id]/page.tsx | 82 +++++ mvp/b2c/src/app/solicitud/actions.ts | 114 +++++-- .../components/ContactForm/ContactForm.tsx | 2 +- mvp/b2c/src/components/Hero/Hero.tsx | 2 +- .../src/components/funnel/FormularioZonas.tsx | 286 ++++++++++++++++++ .../src/components/funnel/FotosUploader.tsx | 209 ------------- 8 files changed, 499 insertions(+), 274 deletions(-) create mode 100644 mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx create mode 100644 mvp/b2c/src/app/solicitud/[id]/page.tsx create mode 100644 mvp/b2c/src/components/funnel/FormularioZonas.tsx delete mode 100644 mvp/b2c/src/components/funnel/FotosUploader.tsx diff --git a/mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx b/mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx new file mode 100644 index 0000000..4d542be --- /dev/null +++ b/mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx @@ -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 && } +
+
+ + Cuéntanos tu reforma + +

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

+

+ 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. +

+
+ +
+ +
+
+ + ); +} diff --git a/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx b/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx index 2f3675c..fd890ac 100644 --- a/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx +++ b/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx @@ -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 && } -
-
- - 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. -

-
- -
- -
-
- - ); + redirect(`/solicitud/${id}/formulario`); } diff --git a/mvp/b2c/src/app/solicitud/[id]/page.tsx b/mvp/b2c/src/app/solicitud/[id]/page.tsx new file mode 100644 index 0000000..2fd5062 --- /dev/null +++ b/mvp/b2c/src/app/solicitud/[id]/page.tsx @@ -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 && } +
+
+ + Elige cómo seguir + +

+ ¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}? +

+

+ Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render + y tu presupuesto. +

+
+ +
+ {CANALES.map((c) => ( + + +
+ {c.titulo} + {c.descripcion} + + {c.cta} → + +
+ + ))} +
+
+ + ); +} diff --git a/mvp/b2c/src/app/solicitud/actions.ts b/mvp/b2c/src/app/solicitud/actions.ts index ddc8940..2a4465a 100644 --- a/mvp/b2c/src/app/solicitud/actions.ts +++ b/mvp/b2c/src/app/solicitud/actions.ts @@ -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 { 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--tipo / -m2 / -calidad / -notas / -fotos). +async function parsearZonas(formData: FormData): Promise { + 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 { 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`); } diff --git a/mvp/b2c/src/components/ContactForm/ContactForm.tsx b/mvp/b2c/src/components/ContactForm/ContactForm.tsx index 326911a..3f43b75 100644 --- a/mvp/b2c/src/components/ContactForm/ContactForm.tsx +++ b/mvp/b2c/src/components/ContactForm/ContactForm.tsx @@ -111,7 +111,7 @@ export default function ContactForm({ slug }: { slug: string }) { setSubmitError(result.error); return; } - router.push(`/solicitud/${result.leadId}/fotos`); + router.push(`/solicitud/${result.leadId}`); }; const handleReset = () => { diff --git a/mvp/b2c/src/components/Hero/Hero.tsx b/mvp/b2c/src/components/Hero/Hero.tsx index 1590409..94653cd 100644 --- a/mvp/b2c/src/components/Hero/Hero.tsx +++ b/mvp/b2c/src/components/Hero/Hero.tsx @@ -91,7 +91,7 @@ function LeadForm({ slug }: { slug: string }) { setSubmitError(result.error); return; } - router.push(`/solicitud/${result.leadId}/fotos`); + router.push(`/solicitud/${result.leadId}`); }; const handleReset = () => { diff --git a/mvp/b2c/src/components/funnel/FormularioZonas.tsx b/mvp/b2c/src/components/funnel/FormularioZonas.tsx new file mode 100644 index 0000000..2e7cd38 --- /dev/null +++ b/mvp/b2c/src/components/funnel/FormularioZonas.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import { TIPO_LABEL } from '@/lib/funnel'; + +const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const; +const CALIDADES = [ + { value: 'basica', label: 'Básica' }, + { value: 'media', label: 'Media' }, + { value: 'premium', label: 'Premium' }, +] as const; +const URGENCIAS = [ + { value: 'alta', label: 'Cuanto antes' }, + { value: 'media', label: 'En unos meses' }, + { value: 'baja', label: 'Sin prisa' }, +] as const; + +const MAX_ZONAS = 6; +const MAX_FOTOS_ZONA = 6; + +const inputClass = + 'w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] border-gray-200 rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)]'; + +type Zona = { key: number; tipo: string }; + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +function ZonaCard({ + index, + zona, + onTipoChange, + onRemove, + removable, +}: { + index: number; + zona: Zona; + onTipoChange: (tipo: string) => void; + onRemove: () => void; + removable: boolean; +}) { + const [previews, setPreviews] = useState([]); + + const handleFiles = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []).slice(0, MAX_FOTOS_ZONA); + previews.forEach((url) => URL.revokeObjectURL(url)); + setPreviews(files.map((f) => URL.createObjectURL(f))); + }; + + return ( +
+
+ Zona {index + 1} + {removable && ( + + )} +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +