From 2bc34d40179e85127d913855b9f38d28b928f28a Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Thu, 4 Jun 2026 14:35:02 +0200 Subject: [PATCH] =?UTF-8?q?Mejora=20los=20emails=20transaccionales=20(dise?= =?UTF-8?q?=C3=B1o=20premium=20+=20marca)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reescribe los emails del funnel (entrega del presupuesto + enlace al formulario) con plantilla HTML mobile-first de una columna, dark mode, botón "bulletproof" en tabla, tipografías de sistema y versión en texto plano. - mailer.ts: construirEmailHtml/construirEmailText compartidos; acento en el color de marca del reformista (resolveTheme) + logo si es URL absoluta; copy con jerarquía y CTA orientado a resultado. - finalizar.ts: pasa la marca del tenant y un CTA de contacto (wa.me del reformista o mailto) al email de entrega. - actions.ts: pasa la marca al email con el enlace al formulario. - COPY-GUIDE §6.b: asuntos (+alternativas), preheader, headline y cuerpo mejorados. Probado: render visual (light) de ambos emails y envío real por SMTP. Co-Authored-By: Claude Opus 4.8 --- copy/COPY-GUIDE.md | 57 +++++---- mvp/b2c/src/app/solicitud/actions.ts | 8 ++ mvp/b2c/src/lib/email/mailer.ts | 182 ++++++++++++++++++++++++--- mvp/b2c/src/lib/funnel/finalizar.ts | 18 +++ 4 files changed, 221 insertions(+), 44 deletions(-) diff --git a/copy/COPY-GUIDE.md b/copy/COPY-GUIDE.md index 5dae4ac..8507714 100644 --- a/copy/COPY-GUIDE.md +++ b/copy/COPY-GUIDE.md @@ -582,47 +582,54 @@ Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo, igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente. +Diseño: HTML transaccional mobile-first, una columna (máx. 600px), dark mode, botón "bulletproof" +(tabla), tipografías de sistema, color de acento = color de marca del reformista. Cada email lleva +asunto (≤50 car.), preheader (texto de previsualización) y versión en texto plano. + ### Email de entrega del presupuesto (PDF adjunto) -Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. +Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. Tono: la entrega es el +protagonista, cálido pero claro. -- **Asunto:** *Tu presupuesto de reforma con [Reformista] ya está listo* +- **Asunto (elegido):** *Aquí está tu presupuesto de reforma* +- **Asunto (alt. A):** *Tu reforma, en números y en imágenes* +- **Asunto (alt. B):** *[Reformista]: tu presupuesto ya está listo* +- **Preheader:** *Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).* +- **Headline:** *Aquí está tu presupuesto, [Nombre]* - **Cuerpo:** -> Hola [Nombre], +> Hemos preparado el **presupuesto orientativo** de tu reforma. En el PDF adjunto tienes el render de +> cómo quedaría tu espacio y el desglose por partidas. > -> Aquí tienes tu **presupuesto orientativo de reforma**, preparado por [Reformista]. Lo encontrarás -> adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas. +> *Es una estimación.* El precio definitivo lo confirma **[Reformista]** en una **visita gratuita** en +> tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso. > -> ⚠️ Es una **estimación**. El precio definitivo lo confirma [Reformista] en una visita gratuita en -> tu casa, donde mide todo con detalle y lo ajusta. -> -> Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin -> compromiso. -> -> — -> [Reformista] +> ¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos. + +- **CTA (si hay teléfono/email del reformista):** *Agendar mi visita gratuita* +- **Footer:** *Presupuesto orientativo. El precio final puede variar según la visita técnica. · [Reformista]* ### Email con enlace al formulario (subir imágenes) Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos -del espacio. `[url]` apunta a su formulario personal del funnel. +del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola acción clara. -- **Asunto:** *Sube las fotos de tu reforma para [Reformista]* +- **Asunto (elegido):** *Sube las fotos de tu reforma* +- **Asunto (alt. A):** *Un paso más para tu presupuesto* +- **Asunto (alt. B):** *[Reformista] necesita ver tu espacio* +- **Preheader:** *Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.* +- **Headline:** *Enséñanos tu espacio, [Nombre]* - **Cuerpo:** -> Hola [Nombre], +> Para preparar tu render y tu presupuesto, **[Reformista]** necesita ver cómo está ahora tu espacio. > -> Para preparar tu render y tu presupuesto, [Reformista] necesita ver el espacio. Sube unas fotos -> de cada zona desde este enlace, cuando te venga bien: +> Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que +> quieras. > -> 👉 [Subir mis fotos]([url]) -> -> Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu -> presupuesto. -> -> — -> [Reformista] +> En cuanto las tengamos, seguimos con tu presupuesto. + +- **CTA:** *Subir mis fotos* → `[url]` +- **Footer:** *[Reformista]* --- diff --git a/mvp/b2c/src/app/solicitud/actions.ts b/mvp/b2c/src/app/solicitud/actions.ts index a30854d..395cb62 100644 --- a/mvp/b2c/src/app/solicitud/actions.ts +++ b/mvp/b2c/src/app/solicitud/actions.ts @@ -14,6 +14,7 @@ import { señalarPerfilCompleto } from '@/lib/funnel/perfil'; import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell'; import { iniciarConversacionWhatsapp } from '@/lib/webhooks'; import { enviarEnlaceFormulario } from '@/lib/email/mailer'; +import { resolveTheme } from '@/lib/funnel/themes'; import { env } from '@/lib/env'; const MAX_ZONAS = 6; @@ -256,12 +257,19 @@ export async function enviarEnlaceFormularioEmail(leadId: string): Promise nombre.split(' ')[0] || nombre; + +// Color de marca del reformista para el acento del email (botón, barra, enlaces). +export type EmailBrand = { + primary: string; + primaryDark: string; + contrast: string; + logoUrl?: string | null; +}; + +const BRAND_DEFECTO: EmailBrand = { primary: '#0a0a0a', primaryDark: '#1a1a1a', contrast: '#ffffff' }; + +type EmailParts = { + brand: EmailBrand; + empresa: string; + preheader: string; + headline: string; + parrafosHtml: string[]; // HTML seguro: las variables ya vienen escapadas + cta?: { url: string; label: string } | null; + footer: string; +}; + +const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"; + +// Email transaccional: una columna (máx 600px), mobile-first, dark mode, botón bulletproof (tabla), +// estilos inline como base + + + +
${escapeHtml(p.preheader)}
+ + +
+ + + + + + + +
+ +`; +} + +function construirEmailText(p: EmailParts): string { + const lineas = [p.headline, '', ...p.parrafosHtml.map(stripHtml), '']; + if (p.cta) lineas.push(`${p.cta.label}: ${p.cta.url}`, ''); + lineas.push('—', p.footer); + return lineas.join('\n'); +} + +const stripHtml = (html: string) => + html + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + // Email de entrega del presupuesto con el PDF adjunto (COPY-GUIDE §6.b). Best-effort: si no hay // SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline. export async function enviarPresupuestoEmail(opts: { @@ -31,24 +155,34 @@ export async function enviarPresupuestoEmail(opts: { empresa: string; pdf: Buffer; filename: string; + brand?: EmailBrand; + cta?: { url: string; label: string } | null; }): Promise { const transport = getTransport(); if (!transport) return false; - const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre); - const empresa = escapeHtml(opts.empresa); - const html = `

Hola ${nombre},

-

Aquí tienes tu presupuesto orientativo de reforma, preparado por ${empresa}. Lo encontrarás adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.

-

⚠️ Es una estimación. El precio definitivo lo confirma ${empresa} en una visita gratuita en tu casa, donde mide todo con detalle y lo ajusta.

-

Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin compromiso.

-


${empresa}

`; + const empresaB = `${escapeHtml(opts.empresa)}`; + const parts: EmailParts = { + brand: opts.brand ?? BRAND_DEFECTO, + empresa: opts.empresa, + preheader: 'Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).', + headline: `Aquí está tu presupuesto, ${primerNombre(opts.nombre)}`, + parrafosHtml: [ + `Hemos preparado el presupuesto orientativo de tu reforma. En el PDF adjunto tienes el render de cómo quedaría tu espacio y el desglose por partidas.`, + `Es una estimación. El precio definitivo lo confirma ${empresaB} en una visita gratuita en tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.`, + `¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.`, + ], + cta: opts.cta ?? null, + footer: `Presupuesto orientativo. El precio final puede variar según la visita técnica. · ${opts.empresa}`, + }; try { await transport.sendMail({ from: env.EMAIL_FROM, to: opts.to, - subject: `Tu presupuesto de reforma con ${opts.empresa} ya está listo`, - html, + subject: 'Aquí está tu presupuesto de reforma', + html: construirEmailHtml(parts), + text: construirEmailText(parts), attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }], }); return true; @@ -65,25 +199,33 @@ export async function enviarEnlaceFormulario(opts: { nombre: string; empresa: string; url: string; + brand?: EmailBrand; }): Promise { const transport = getTransport(); if (!transport) return false; - const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre); - const empresa = escapeHtml(opts.empresa); - const url = encodeURI(opts.url); - const html = `

Hola ${nombre},

-

Para preparar tu render y tu presupuesto, ${empresa} necesita ver el espacio. Sube unas fotos de cada zona desde este enlace, cuando te venga bien:

-

👉 Subir mis fotos

-

Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu presupuesto.

-


${empresa}

`; + const empresaB = `${escapeHtml(opts.empresa)}`; + const parts: EmailParts = { + brand: opts.brand ?? BRAND_DEFECTO, + empresa: opts.empresa, + preheader: 'Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.', + headline: `Enséñanos tu espacio, ${primerNombre(opts.nombre)}`, + parrafosHtml: [ + `Para preparar tu render y tu presupuesto, ${empresaB} necesita ver cómo está ahora tu espacio.`, + `Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que quieras.`, + `En cuanto las tengamos, seguimos con tu presupuesto.`, + ], + cta: { url: encodeURI(opts.url), label: 'Subir mis fotos' }, + footer: opts.empresa, + }; try { await transport.sendMail({ from: env.EMAIL_FROM, to: opts.to, - subject: `Sube las fotos de tu reforma para ${opts.empresa}`, - html, + subject: 'Sube las fotos de tu reforma', + html: construirEmailHtml(parts), + text: construirEmailText(parts), }); return true; } catch (err) { @@ -91,3 +233,5 @@ export async function enviarEnlaceFormulario(opts: { return false; } } + +export { construirEmailHtml, construirEmailText }; diff --git a/mvp/b2c/src/lib/funnel/finalizar.ts b/mvp/b2c/src/lib/funnel/finalizar.ts index 0841583..ccb7fd4 100644 --- a/mvp/b2c/src/lib/funnel/finalizar.ts +++ b/mvp/b2c/src/lib/funnel/finalizar.ts @@ -4,6 +4,8 @@ import { leads, leadPipelineEventos } from '@/db/schema'; import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto'; import { enviarPresupuestoEmail } from '@/lib/email/mailer'; import { notificarFlujoWhatsapp } from '@/lib/webhooks'; +import { resolveTheme } from '@/lib/funnel/themes'; +import { normalizarTelefonoEs } from '@/lib/voice/retell'; export type ResultadoFinalizar = { ok: boolean; @@ -27,6 +29,15 @@ export async function finalizarYEntregar(leadId: string): Promise