Enlace del email = subir solo fotos (sin re-preguntar ni re-llamar)

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 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-07 19:33:15 +02:00
parent 98f02eb02e
commit 508fc43f1f
5 changed files with 222 additions and 25 deletions

View File

@@ -362,6 +362,17 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
**Botón confirmar:** Lo he recibido **Botón confirmar:** Lo he recibido
- **Agradecimiento:** ✅ ¡Genial, [Nombre]! Seguimos por WhatsApp. Allí te pediremos las fotos y los detalles para preparar tu presupuesto. - **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) ### Subida de fotos (paso 2 del wizard)
- **Título del paso:** Ahora una foto de tu espacio actual - **Título del paso:** Ahora una foto de tu espacio actual

View File

@@ -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'; export const dynamic = 'force-dynamic';
// La subida de fotos vive ahora en /formulario (formulario por zonas). Mantenemos /fotos como // Página ligera (enlace del email): el cliente solo sube fotos del espacio. No re-pregunta ni
// redirect por compatibilidad con enlaces antiguos. // vuelve a llamar; las fotos van a ESTE lead (id de la URL).
export default async function FotosRedirect({ params }: { params: Promise<{ id: string }> }) { export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
redirect(`/solicitud/${id}/formulario`); 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">
Solo falta esto
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Sube las fotos de tu espacio, {lead.nombre.split(' ')[0]}
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Con un par de fotos del espacio actual preparamos tu render y afinamos el presupuesto.
Tardas un minuto.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<SubirFotos action={subirFotos.bind(null, id)} />
</div>
</div>
</>
);
} }

View File

@@ -1,7 +1,7 @@
'use server'; 'use server';
import { z } from 'zod'; import { z } from 'zod';
import { and, eq } from 'drizzle-orm'; import { and, desc, eq } from 'drizzle-orm';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { db } from '@/db'; import { db } from '@/db';
@@ -257,13 +257,66 @@ export async function pedirLlamada(
return { ok: true, programada: programadaAt ?? undefined }; 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<void> {
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<boolean> { export async function enviarEnlaceFormularioEmail(leadId: string): Promise<boolean> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return false; if (!lead) return false;
const tenant = await getTenantPerfilById(lead.tenantId); const tenant = await getTenantPerfilById(lead.tenantId);
const theme = resolveTheme(tenant.themePreset, tenant.themeColor); 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({ return enviarEnlaceFormulario({
to: lead.email, to: lead.email,
nombre: lead.nombre, nombre: lead.nombre,

View File

@@ -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 (
<button
type="submit"
className="btn btn-primary btn-lg w-full justify-center mt-1 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
disabled={pending}
aria-busy={pending}
>
{pending ? (
<>
<span
className="w-[18px] h-[18px] border-2 border-white/30 border-t-white rounded-full animate-[spin_0.7s_linear_infinite] shrink-0"
aria-hidden="true"
/>
Enviando
</>
) : (
'Enviar mis fotos'
)}
</button>
);
}
export default function SubirFotos({
action,
}: {
action: (formData: FormData) => void | Promise<void>;
}) {
const [previews, setPreviews] = useState<string[]>([]);
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<form action={action} className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="fotos" className="text-sm font-semibold text-dark">
Fotos del espacio <span className="text-gray-400 font-normal">(hasta {MAX_FOTOS})</span>
</label>
<input
id="fotos"
name="fotos"
type="file"
accept="image/*"
multiple
onChange={handleFiles}
className="block w-full text-sm text-gray-600 file:mr-4 file:py-2.5 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-black file:text-white hover:file:bg-gray-800 file:cursor-pointer cursor-pointer"
/>
{previews.length > 0 && (
<div className="flex flex-wrap gap-3 mt-2">
{previews.map((url, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={url}
alt=""
className="w-24 h-24 object-cover rounded-lg border border-gray-200"
/>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="nota" className="text-sm font-semibold text-dark">
¿Algo que quieras añadir? <span className="text-gray-400 font-normal">(opcional)</span>
</label>
<textarea id="nota" name="nota" rows={3} className={inputClass} />
</div>
<SubmitButton />
</form>
);
}

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { leads, leadPipelineEventos, tenants } from '@/db/schema'; import { leads, leadPipelineEventos, tenants } from '@/db/schema';
import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries'; import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries';
@@ -80,15 +80,28 @@ export async function procesarLead(leadId: string): Promise<void> {
// Paso 5: llamada del agente IA. Si Retell está configurado se lanza una llamada saliente // Paso 5: llamada del agente IA. Si Retell está configurado se lanza una llamada saliente
// REAL (el móvil del lead suena y el agente habla con sus variables); el render y el // REAL (el móvil del lead suena y el agente habla con sus variables); el render y el
// presupuesto se siguen generando con los datos del formulario (Arquitectura A de la demo). // presupuesto se siguen generando con los datos del formulario (Arquitectura A de la demo).
const llamada = await iniciarLlamadaSaliente({ // Guarda: si el lead YA tiene una llamada, no se vuelve a llamar (p. ej. re-envío del form).
const [yaLlamado] = await db
.select({ id: leadPipelineEventos.id })
.from(leadPipelineEventos)
.where(
and(eq(leadPipelineEventos.leadId, leadId), eq(leadPipelineEventos.stage, 'llamada_completada')),
)
.limit(1);
const llamada = yaLlamado
? null
: await iniciarLlamadaSaliente({
telefono: lead.telefono, telefono: lead.telefono,
variables: construirVariablesLlamada({ nombreEmpresa }, lead), variables: construirVariablesLlamada({ nombreEmpresa }, lead),
leadId, leadId,
}); });
// Si la llamada es REAL, la transcripción real la rellena el webhook de Retell al colgar; no // Si la llamada es REAL, la transcripción real la rellena el webhook de Retell al colgar; no
// guardamos el placeholder. Solo usamos el transcript simulado cuando no hay llamada real. // guardamos el placeholder. Solo usamos el transcript simulado cuando no hay llamada real.
const transcripcion = llamada ? null : construirTranscripcion(lead); // Si el lead ya estaba llamado (yaLlamado), no tocamos la transcripción (undefined = no cambiar)
// para no pisar la que dejó el webhook.
const transcripcion = yaLlamado ? undefined : llamada ? null : construirTranscripcion(lead);
const entidades = construirEntidades(lead); const entidades = construirEntidades(lead);
if (!yaLlamado) {
await db.insert(leadPipelineEventos).values({ await db.insert(leadPipelineEventos).values({
leadId, leadId,
stage: 'llamada_completada', stage: 'llamada_completada',
@@ -99,6 +112,7 @@ export async function procesarLead(leadId: string): Promise<void> {
duracionSeg: 95, duracionSeg: 95,
}, },
}); });
}
// Paso 6a: render IA generado // Paso 6a: render IA generado
const renderUrl = RENDER_POR_TIPO[tipo]; const renderUrl = RENDER_POR_TIPO[tipo];