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,
|
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.
|
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)
|
### 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:**
|
- **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
|
> *Es una estimación.* El precio definitivo lo confirma **[Reformista]** en una **visita gratuita** en
|
||||||
> adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.
|
> 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
|
> ¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.
|
||||||
> tu casa, donde mide todo con detalle y lo ajusta.
|
|
||||||
>
|
- **CTA (si hay teléfono/email del reformista):** *Agendar mi visita gratuita*
|
||||||
> Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin
|
- **Footer:** *Presupuesto orientativo. El precio final puede variar según la visita técnica. · [Reformista]*
|
||||||
> compromiso.
|
|
||||||
>
|
|
||||||
> —
|
|
||||||
> [Reformista]
|
|
||||||
|
|
||||||
### Email con enlace al formulario (subir imágenes)
|
### 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
|
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:**
|
- **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
|
> Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que
|
||||||
> de cada zona desde este enlace, cuando te venga bien:
|
> quieras.
|
||||||
>
|
>
|
||||||
> 👉 [Subir mis fotos]([url])
|
> En cuanto las tengamos, seguimos con tu presupuesto.
|
||||||
>
|
|
||||||
> Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu
|
- **CTA:** *Subir mis fotos* → `[url]`
|
||||||
> presupuesto.
|
- **Footer:** *[Reformista]*
|
||||||
>
|
|
||||||
> —
|
|
||||||
> [Reformista]
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
|
|||||||
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
|
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
|
||||||
import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
|
import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
|
||||||
import { enviarEnlaceFormulario } from '@/lib/email/mailer';
|
import { enviarEnlaceFormulario } from '@/lib/email/mailer';
|
||||||
|
import { resolveTheme } from '@/lib/funnel/themes';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
|
|
||||||
const MAX_ZONAS = 6;
|
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);
|
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 url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
|
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
|
||||||
return enviarEnlaceFormulario({
|
return enviarEnlaceFormulario({
|
||||||
to: lead.email,
|
to: lead.email,
|
||||||
nombre: lead.nombre,
|
nombre: lead.nombre,
|
||||||
empresa: tenant.nombreEmpresa,
|
empresa: tenant.nombreEmpresa,
|
||||||
url,
|
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
|
// 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.
|
// SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline.
|
||||||
export async function enviarPresupuestoEmail(opts: {
|
export async function enviarPresupuestoEmail(opts: {
|
||||||
@@ -31,24 +155,34 @@ export async function enviarPresupuestoEmail(opts: {
|
|||||||
empresa: string;
|
empresa: string;
|
||||||
pdf: Buffer;
|
pdf: Buffer;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
brand?: EmailBrand;
|
||||||
|
cta?: { url: string; label: string } | null;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
if (!transport) return false;
|
if (!transport) return false;
|
||||||
|
|
||||||
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
|
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
|
||||||
const empresa = escapeHtml(opts.empresa);
|
const parts: EmailParts = {
|
||||||
const html = `<p>Hola ${nombre},</p>
|
brand: opts.brand ?? BRAND_DEFECTO,
|
||||||
<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>
|
empresa: opts.empresa,
|
||||||
<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>
|
preheader: 'Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).',
|
||||||
<p>Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin compromiso.</p>
|
headline: `Aquí está tu presupuesto, ${primerNombre(opts.nombre)}`,
|
||||||
<p>—<br/>${empresa}</p>`;
|
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 {
|
try {
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: env.EMAIL_FROM,
|
from: env.EMAIL_FROM,
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
subject: `Tu presupuesto de reforma con ${opts.empresa} ya está listo`,
|
subject: 'Aquí está tu presupuesto de reforma',
|
||||||
html,
|
html: construirEmailHtml(parts),
|
||||||
|
text: construirEmailText(parts),
|
||||||
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
|
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -65,25 +199,33 @@ export async function enviarEnlaceFormulario(opts: {
|
|||||||
nombre: string;
|
nombre: string;
|
||||||
empresa: string;
|
empresa: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
brand?: EmailBrand;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
if (!transport) return false;
|
if (!transport) return false;
|
||||||
|
|
||||||
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
|
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
|
||||||
const empresa = escapeHtml(opts.empresa);
|
const parts: EmailParts = {
|
||||||
const url = encodeURI(opts.url);
|
brand: opts.brand ?? BRAND_DEFECTO,
|
||||||
const html = `<p>Hola ${nombre},</p>
|
empresa: opts.empresa,
|
||||||
<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>
|
preheader: 'Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.',
|
||||||
<p>👉 <a href="${url}">Subir mis fotos</a></p>
|
headline: `Enséñanos tu espacio, ${primerNombre(opts.nombre)}`,
|
||||||
<p>Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu presupuesto.</p>
|
parrafosHtml: [
|
||||||
<p>—<br/>${empresa}</p>`;
|
`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 {
|
try {
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: env.EMAIL_FROM,
|
from: env.EMAIL_FROM,
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
subject: `Sube las fotos de tu reforma para ${opts.empresa}`,
|
subject: 'Sube las fotos de tu reforma',
|
||||||
html,
|
html: construirEmailHtml(parts),
|
||||||
|
text: construirEmailText(parts),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -91,3 +233,5 @@ export async function enviarEnlaceFormulario(opts: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { construirEmailHtml, construirEmailText };
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { leads, leadPipelineEventos } from '@/db/schema';
|
|||||||
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
|
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
|
||||||
import { enviarPresupuestoEmail } from '@/lib/email/mailer';
|
import { enviarPresupuestoEmail } from '@/lib/email/mailer';
|
||||||
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
|
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
|
||||||
|
import { resolveTheme } from '@/lib/funnel/themes';
|
||||||
|
import { normalizarTelefonoEs } from '@/lib/voice/retell';
|
||||||
|
|
||||||
export type ResultadoFinalizar = {
|
export type ResultadoFinalizar = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -27,6 +29,15 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
|
|||||||
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
|
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
|
||||||
.where(eq(leads.id, leadId));
|
.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([
|
const [emailEnviado, whatsappSenal] = await Promise.all([
|
||||||
enviarPresupuestoEmail({
|
enviarPresupuestoEmail({
|
||||||
to: lead.email,
|
to: lead.email,
|
||||||
@@ -34,6 +45,13 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
|
|||||||
empresa: tenant.nombreEmpresa,
|
empresa: tenant.nombreEmpresa,
|
||||||
pdf: buffer,
|
pdf: buffer,
|
||||||
filename,
|
filename,
|
||||||
|
brand: {
|
||||||
|
primary: theme.primary,
|
||||||
|
primaryDark: theme.primaryDark,
|
||||||
|
contrast: theme.contrast,
|
||||||
|
logoUrl: tenant.logoUrl,
|
||||||
|
},
|
||||||
|
cta,
|
||||||
}),
|
}),
|
||||||
notificarFlujoWhatsapp({
|
notificarFlujoWhatsapp({
|
||||||
leadId,
|
leadId,
|
||||||
|
|||||||
Reference in New Issue
Block a user