Mejora los emails transaccionales (diseño premium + marca)

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 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-04 14:35:02 +02:00
parent 2e3cd78216
commit 2bc34d4017
4 changed files with 221 additions and 44 deletions

View File

@@ -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]*
---

View File

@@ -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<boole
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`;
return enviarEnlaceFormulario({
to: lead.email,
nombre: lead.nombre,
empresa: tenant.nombreEmpresa,
url,
brand: {
primary: theme.primary,
primaryDark: theme.primaryDark,
contrast: theme.contrast,
logoUrl: tenant.logoUrl,
},
});
}

View File

@@ -23,6 +23,130 @@ function escapeHtml(s: string): string {
);
}
const primerNombre = (nombre: string) => 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 + <style> para dark/responsive. Compatible con Gmail/Apple Mail/Outlook.
function construirEmailHtml(p: EmailParts): string {
const empresa = escapeHtml(p.empresa);
const logo =
p.brand.logoUrl && /^https?:\/\//.test(p.brand.logoUrl)
? `<img src="${escapeHtml(p.brand.logoUrl)}" alt="${empresa}" height="34" style="max-height:34px;max-width:180px;display:block;border:0;" />`
: `<span style="font-size:18px;font-weight:700;color:#1a1a1a;letter-spacing:-0.2px;">${empresa}</span>`;
const cta = p.cta
? `<tr><td style="height:28px;line-height:28px;">&nbsp;</td></tr>
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">
<tr><td style="border-radius:10px;background:${p.brand.primary};">
<a class="btn-a" href="${escapeHtml(p.cta.url)}" target="_blank"
style="display:inline-block;padding:15px 30px;font-family:${FONT};font-size:16px;font-weight:600;line-height:1;color:${p.brand.contrast};text-decoration:none;border-radius:10px;">
${escapeHtml(p.cta.label)}
</a>
</td></tr>
</table>
</td></tr>`
: '';
const parrafos = p.parrafosHtml
.map(
(html) =>
`<tr><td class="email-text" style="font-family:${FONT};font-size:16px;line-height:1.65;color:#3f3f46;padding-bottom:16px;">${html}</td></tr>`,
)
.join('');
return `<!DOCTYPE html>
<html lang="es" style="margin:0;padding:0;">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>${empresa}</title>
<style>
@media (prefers-color-scheme: dark) {
.email-body { background:#0b0b0c !important; }
.email-card { background:#18181b !important; }
.email-head { background:#18181b !important; }
.email-title { color:#fafafa !important; }
.email-text { color:#d4d4d8 !important; }
.email-muted { color:#a1a1aa !important; }
.email-rule { border-color:#27272a !important; }
.email-logo-name { color:#fafafa !important; }
}
@media (max-width:600px) {
.email-card { width:100% !important; border-radius:0 !important; }
.email-pad { padding-left:24px !important; padding-right:24px !important; }
.btn-a { display:block !important; padding-left:0 !important; padding-right:0 !important; width:100%; box-sizing:border-box; text-align:center; }
}
</style>
</head>
<body class="email-body" style="margin:0;padding:0;background:#f4f4f5;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(p.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f4f4f5;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="email-card" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;">
<tr><td style="height:4px;line-height:4px;background:${p.brand.primary};font-size:0;">&nbsp;</td></tr>
<tr><td class="email-head email-pad" style="padding:24px 40px 8px 40px;">
<span class="email-logo-name">${logo}</span>
</td></tr>
<tr><td class="email-pad" style="padding:8px 40px 0 40px;">
<h1 class="email-title" style="margin:0 0 18px 0;font-family:${FONT};font-size:26px;line-height:1.25;font-weight:800;color:#18181b;letter-spacing:-0.4px;">${escapeHtml(p.headline)}</h1>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
${parrafos}
${cta}
</table>
</td></tr>
<tr><td class="email-pad" style="padding:28px 40px 0 40px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="email-rule" style="border-top:1px solid #e4e4e7;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="email-pad email-muted" style="padding:16px 40px 32px 40px;font-family:${FONT};font-size:13px;line-height:1.5;color:#71717a;">${escapeHtml(p.footer)}</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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<boolean> {
const transport = getTransport();
if (!transport) return false;
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
const empresa = escapeHtml(opts.empresa);
const html = `<p>Hola ${nombre},</p>
<p>Aquí tienes tu <strong>presupuesto orientativo de reforma</strong>, preparado por ${empresa}. Lo encontrarás adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.</p>
<p>⚠️ Es una <strong>estimación</strong>. El precio definitivo lo confirma ${empresa} en una visita gratuita en tu casa, donde mide todo con detalle y lo ajusta.</p>
<p>Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin compromiso.</p>
<p>—<br/>${empresa}</p>`;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
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 <strong>presupuesto orientativo</strong> de tu reforma. En el PDF adjunto tienes el render de cómo quedaría tu espacio y el desglose por partidas.`,
`<em>Es una estimación.</em> El precio definitivo lo confirma ${empresaB} en una <strong>visita gratuita</strong> 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<boolean> {
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 = `<p>Hola ${nombre},</p>
<p>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:</p>
<p>👉 <a href="${url}">Subir mis fotos</a></p>
<p>Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu presupuesto.</p>
<p>—<br/>${empresa}</p>`;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
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 };

View File

@@ -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<ResultadoFinal
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
.where(eq(leads.id, leadId));
// Marca del reformista para el acento del email + CTA de contacto (WhatsApp o email).
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
const tel = tenant.telefono ? normalizarTelefonoEs(tenant.telefono) : null;
const cta = tel
? { url: `https://wa.me/${tel.replace('+', '')}`, label: 'Agendar mi visita gratuita' }
: tenant.email
? { url: `mailto:${tenant.email}`, label: 'Agendar mi visita gratuita' }
: null;
const [emailEnviado, whatsappSenal] = await Promise.all([
enviarPresupuestoEmail({
to: lead.email,
@@ -34,6 +45,13 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
empresa: tenant.nombreEmpresa,
pdf: buffer,
filename,
brand: {
primary: theme.primary,
primaryDark: theme.primaryDark,
contrast: theme.contrast,
logoUrl: tenant.logoUrl,
},
cta,
}),
notificarFlujoWhatsapp({
leadId,