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 (
+
+ );
+}
diff --git a/mvp/b2c/src/lib/funnel/orchestrator.ts b/mvp/b2c/src/lib/funnel/orchestrator.ts
index 6b40865..e8581b9 100644
--- a/mvp/b2c/src/lib/funnel/orchestrator.ts
+++ b/mvp/b2c/src/lib/funnel/orchestrator.ts
@@ -1,4 +1,4 @@
-import { eq } from 'drizzle-orm';
+import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadPipelineEventos, tenants } from '@/db/schema';
import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries';
@@ -80,25 +80,39 @@ export async function procesarLead(leadId: string): Promise {
// 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
// presupuesto se siguen generando con los datos del formulario (Arquitectura A de la demo).
- const llamada = await iniciarLlamadaSaliente({
- telefono: lead.telefono,
- variables: construirVariablesLlamada({ nombreEmpresa }, lead),
- leadId,
- });
+ // 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,
+ variables: construirVariablesLlamada({ nombreEmpresa }, lead),
+ leadId,
+ });
// 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.
- 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);
- await db.insert(leadPipelineEventos).values({
- leadId,
- stage: 'llamada_completada',
- metadata: {
- simulado: !llamada,
- real: Boolean(llamada),
- retellCallId: llamada?.callId,
- duracionSeg: 95,
- },
- });
+ if (!yaLlamado) {
+ await db.insert(leadPipelineEventos).values({
+ leadId,
+ stage: 'llamada_completada',
+ metadata: {
+ simulado: !llamada,
+ real: Boolean(llamada),
+ retellCallId: llamada?.callId,
+ duracionSeg: 95,
+ },
+ });
+ }
// Paso 6a: render IA generado
const renderUrl = RENDER_POR_TIPO[tipo];