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:
@@ -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]*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;"> </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;"> </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;"> </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(/&/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<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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user