From 508fc43f1f2e29e512ace6080298b6bdff6f6cb2 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Sun, 7 Jun 2026 19:33:15 +0200 Subject: [PATCH] Enlace del email = subir solo fotos (sin re-preguntar ni re-llamar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arregla 2 problemas del flujo de subir fotos desde el email: - El enlace iba a /formulario (form completo) y al enviarlo re-ejecutaba procesarLead, que VOLVÍA a llamar. Ahora el email apunta a /solicitud/[id]/fotos, una página ligera (SubirFotos): solo sube fotos (+ nota opcional) al lead de la URL, re-señala perfilCompleto y NO llama. - Guarda en procesarLead: si el lead ya tiene llamada_completada, no se vuelve a llamar (ni se pisa la transcripción real del webhook). Copy de la página en COPY-GUIDE §3. Co-Authored-By: Claude Opus 4.8 --- copy/COPY-GUIDE.md | 11 +++ mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx | 41 +++++++-- mvp/b2c/src/app/solicitud/actions.ts | 59 ++++++++++++- mvp/b2c/src/components/funnel/SubirFotos.tsx | 88 +++++++++++++++++++ mvp/b2c/src/lib/funnel/orchestrator.ts | 48 ++++++---- 5 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 mvp/b2c/src/components/funnel/SubirFotos.tsx diff --git a/copy/COPY-GUIDE.md b/copy/COPY-GUIDE.md index 3afd19b..8463403 100644 --- a/copy/COPY-GUIDE.md +++ b/copy/COPY-GUIDE.md @@ -362,6 +362,17 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll **Botón confirmar:** Lo he recibido - **Agradecimiento:** ✅ ¡Genial, [Nombre]! Seguimos por WhatsApp. Allí te pediremos las fotos y los detalles para preparar tu presupuesto. +### Subida de fotos por enlace (página ligera del email) + +Página a la que lleva el enlace del email (canal llamada). Solo sube fotos; nada de re-preguntar ni +de volver a llamar. + +- **Etiqueta del paso:** Solo falta esto +- **Título:** Sube las fotos de tu espacio, [Nombre] +- **Subtitle:** Con un par de fotos del espacio actual preparamos tu render y afinamos el presupuesto. Tardas un minuto. +- **Nota (opcional):** ¿Algo que quieras añadir? (opcional) +- **Botón:** Enviar mis fotos + ### Subida de fotos (paso 2 del wizard) - **Título del paso:** Ahora una foto de tu espacio actual diff --git a/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx b/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx index fd890ac..05ada28 100644 --- a/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx +++ b/mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx @@ -1,10 +1,41 @@ -import { redirect } from 'next/navigation'; +import { notFound } from 'next/navigation'; +import { getPublicLead } from '@/lib/funnel/public-queries'; +import { subirFotos } from '../../actions'; +import SubirFotos from '@/components/funnel/SubirFotos'; +import TenantBrand from '@/components/funnel/TenantBrand'; export const dynamic = 'force-dynamic'; -// 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 }> }) { +// Página ligera (enlace del email): el cliente solo sube fotos del espacio. No re-pregunta ni +// vuelve a llamar; las fotos van a ESTE lead (id de la URL). +export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - redirect(`/solicitud/${id}/formulario`); + const data = await getPublicLead(id); + if (!data) notFound(); + + const { lead, tenant } = data; + + return ( + <> + {tenant && } +
+
+ + Solo falta esto + +

+ Sube las fotos de tu espacio, {lead.nombre.split(' ')[0]} +

+

+ Con un par de fotos del espacio actual preparamos tu render y afinamos el presupuesto. + Tardas un minuto. +

+
+ +
+ +
+
+ + ); } diff --git a/mvp/b2c/src/app/solicitud/actions.ts b/mvp/b2c/src/app/solicitud/actions.ts index 4d9320b..ebc3bb9 100644 --- a/mvp/b2c/src/app/solicitud/actions.ts +++ b/mvp/b2c/src/app/solicitud/actions.ts @@ -1,7 +1,7 @@ 'use server'; import { z } from 'zod'; -import { and, eq } from 'drizzle-orm'; +import { and, desc, eq } from 'drizzle-orm'; import { redirect } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { db } from '@/db'; @@ -257,13 +257,66 @@ export async function pedirLlamada( return { ok: true, programada: programadaAt ?? undefined }; } -// Canal llamada: envía al cliente un email con el enlace a su formulario para subir las imágenes. +// Página ligera del enlace del email: el cliente solo sube fotos del espacio. NO ejecuta +// procesarLead, así que NO vuelve a llamar (la llamada, si tocaba, ya se hizo). Solo guarda las +// fotos en ESTE lead (id de la URL) y re-señala el perfil para regenerar el render con ellas. +export async function subirFotos(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 archivos = formData + .getAll('fotos') + .filter((f): f is File => f instanceof File) + .slice(0, MAX_FOTOS_ZONA); + const dataUris: string[] = []; + for (const file of archivos) { + const uri = await fileToDataUri(file); + if (uri) dataUris.push(uri); + } + const nota = String(formData.get('nota') ?? '').trim() || null; + + if (dataUris.length > 0) { + const [ultimo] = await db + .select({ orden: leadFotos.orden }) + .from(leadFotos) + .where(eq(leadFotos.leadId, leadId)) + .orderBy(desc(leadFotos.orden)) + .limit(1); + let orden = (ultimo?.orden ?? -1) + 1; + const filas: NewLeadFoto[] = dataUris.map((url) => ({ + leadId, + url, + momento: 'antes', + zona: lead.tipoReforma ?? null, + orden: orden++, + })); + await db.insert(leadFotos).values(filas); + } + if (nota) { + await db + .insert(leadNotas) + .values({ leadId, texto: nota, zona: lead.tipoReforma ?? null, origen: 'funnel' }); + } + await db.insert(leadPipelineEventos).values({ + leadId, + stage: 'fotos_subidas', + metadata: { origen: 'email', fotos: dataUris.length, notas: nota ? 1 : 0 }, + }); + + // Re-señala el perfil para que el flujo externo regenere el render con las fotos nuevas. + await señalarPerfilCompleto(leadId); + + revalidatePath('/panel'); + redirect(`/solicitud/${leadId}/estado`); +} + +// Canal llamada: envía al cliente un email con el enlace para subir las imágenes (página ligera). export async function enviarEnlaceFormularioEmail(leadId: string): Promise { const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); if (!lead) return false; const tenant = await getTenantPerfilById(lead.tenantId); const theme = resolveTheme(tenant.themePreset, tenant.themeColor); - const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`; + const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/fotos`; return enviarEnlaceFormulario({ to: lead.email, nombre: lead.nombre, diff --git a/mvp/b2c/src/components/funnel/SubirFotos.tsx b/mvp/b2c/src/components/funnel/SubirFotos.tsx new file mode 100644 index 0000000..d81b348 --- /dev/null +++ b/mvp/b2c/src/components/funnel/SubirFotos.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { useFormStatus } from 'react-dom'; + +const MAX_FOTOS = 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)]'; + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +export default function SubirFotos({ + 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 ( +
+
+ + + {previews.length > 0 && ( +
+ {previews.map((url, i) => ( + // eslint-disable-next-line @next/next/no-img-element + + ))} +
+ )} +
+ +
+ +