From 61c36dbb7cf446c73593ebec8187d55fce72ec15 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Mon, 15 Dec 2025 19:56:03 -0300 Subject: [PATCH] feat(email): redesign completo dos templates de e-mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sincroniza cores com globals.css e status-badge.tsx - Adiciona botoes grandes e destacados (cyan primary, preto secondary) - Implementa badges pill com border-radius arredondado - Importa fonte Inter do Google Fonts - Adiciona icones em circulos grandes (64px) para templates de status - Cria cards de informacao do ticket bem estruturados - Aumenta espacamentos e padding para layout mais limpo - Centraliza estrelas de avaliacao - Melhora tipografia com pesos bem definidos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/server/email/email-templates.ts | 619 ++++++++++++++++++---------- 1 file changed, 398 insertions(+), 221 deletions(-) diff --git a/src/server/email/email-templates.ts b/src/server/email/email-templates.ts index efaabfa..7a5dbb1 100644 --- a/src/server/email/email-templates.ts +++ b/src/server/email/email-templates.ts @@ -1,6 +1,7 @@ /** * Sistema de Templates de E-mail * Sistema de Chamados Raven + * Design inspirado em boas praticas de e-mail marketing */ // ============================================ @@ -24,48 +25,64 @@ export type TemplateName = export type TemplateData = Record // ============================================ -// Design Tokens +// Design Tokens - Sincronizado com globals.css // ============================================ const COLORS = { - // Primárias + // Primarias - cyan do sistema primary: "#00e8ff", - primaryDark: "#00c4d6", + primaryDark: "#00d6eb", primaryForeground: "#020617", - // Background + // Backgrounds background: "#f7f8fb", card: "#ffffff", + cardAlt: "#f8fafc", + + // Borders border: "#e2e8f0", + borderLight: "#f1f5f9", // Texto textPrimary: "#0f172a", textSecondary: "#475569", - textMuted: "#64748b", + textMuted: "#94a3b8", - // Status + // Status - alinhado com status-badge.tsx statusPending: "#64748b", statusPendingBg: "#f1f5f9", - statusProgress: "#0ea5e9", - statusProgressBg: "#e0f2fe", - statusPaused: "#f59e0b", - statusPausedBg: "#fef3c7", - statusResolved: "#10b981", - statusResolvedBg: "#d1fae5", + statusProgress: "#0a4760", + statusProgressBg: "#dff1fb", + statusPaused: "#7a5901", + statusPausedBg: "#fff3c4", + statusResolved: "#1f6a45", + statusResolvedBg: "#dcf4eb", // Prioridade priorityLow: "#64748b", priorityLowBg: "#f1f5f9", priorityMedium: "#0a4760", priorityMediumBg: "#dff1fb", - priorityHigh: "#7d3b05", - priorityHighBg: "#fde8d1", - priorityUrgent: "#8b0f1c", - priorityUrgentBg: "#fbd9dd", + priorityHigh: "#92400e", + priorityHighBg: "#fef3c7", + priorityUrgent: "#991b1b", + priorityUrgentBg: "#fee2e2", + + // Alertas + warning: "#d97706", + warningBg: "#fffbeb", + error: "#dc2626", + errorBg: "#fef2f2", + success: "#059669", + successBg: "#ecfdf5", // Estrelas - starActive: "#fbbf24", - starInactive: "#d1d5db", + starActive: "#f59e0b", + starInactive: "#e2e8f0", + + // Botao secundario + buttonSecondary: "#0f172a", + buttonSecondaryForeground: "#f8fafc", } // ============================================ @@ -96,6 +113,16 @@ function formatDate(date: Date | string | number, options?: Intl.DateTimeFormatO }) } +function formatDateShort(date: Date | string | number): string { + const d = date instanceof Date ? date : new Date(date) + return d.toLocaleDateString("pt-BR", { + timeZone: "America/Sao_Paulo", + day: "2-digit", + month: "short", + year: "numeric", + }) +} + // ============================================ // Componentes de E-mail // ============================================ @@ -113,15 +140,16 @@ function getStatusStyle(status: string): { bg: string; color: string; label: str function getPriorityStyle(priority: string): { bg: string; color: string; label: string } { const priorityMap: Record = { LOW: { bg: COLORS.priorityLowBg, color: COLORS.priorityLow, label: "Baixa" }, - MEDIUM: { bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium, label: "Média" }, + MEDIUM: { bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium, label: "Media" }, HIGH: { bg: COLORS.priorityHighBg, color: COLORS.priorityHigh, label: "Alta" }, URGENT: { bg: COLORS.priorityUrgentBg, color: COLORS.priorityUrgent, label: "Urgente" }, } return priorityMap[priority] ?? { bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium, label: priority } } +// Badge pill - estilo arredondado como no sistema function badge(label: string, bg: string, color: string): string { - return `${escapeHtml(label)}` + return `${escapeHtml(label)}` } function statusBadge(status: string): string { @@ -134,14 +162,38 @@ function priorityBadge(priority: string): string { return badge(style.label, style.bg, style.color) } -function button(label: string, url: string, variant: "primary" | "secondary" = "primary"): string { - const bg = variant === "primary" ? COLORS.primary : COLORS.card - const color = variant === "primary" ? COLORS.primaryForeground : COLORS.textPrimary - const border = variant === "primary" ? COLORS.primary : COLORS.border - - return `${escapeHtml(label)}` +// Botao principal - estilo cyan do sistema +function buttonPrimary(label: string, url: string): string { + return ` + + + + +
+ ${escapeHtml(label)} +
+ ` } +// Botao secundario - estilo escuro +function buttonSecondary(label: string, url: string): string { + return ` + + + + +
+ ${escapeHtml(label)} +
+ ` +} + +// Link de texto +function textLink(label: string, url: string): string { + return `${escapeHtml(label)}` +} + +// Card de informacoes do ticket function ticketInfoCard(data: { reference: number | string subject: string @@ -153,98 +205,139 @@ function ticketInfoCard(data: { }): string { const rows: string[] = [] + // Numero do chamado com destaque rows.push(` - Chamado - #${escapeHtml(data.reference)} + + + + + + +
Chamado#${escapeHtml(data.reference)}
+ `) + // Assunto rows.push(` - Assunto - ${escapeHtml(data.subject)} + + + + + + +
Assunto${escapeHtml(data.subject)}
+ `) - if (data.status) { + // Status e Prioridade na mesma linha + if (data.status || data.priority) { rows.push(` - Status - ${statusBadge(data.status)} + + + + ${data.status ? ` + + ` : ""} + ${data.priority ? ` + + ` : ""} + +
+ Status + ${statusBadge(data.status)} + + Prioridade + ${priorityBadge(data.priority)} +
+ `) } - if (data.priority) { + // Solicitante e Responsavel + if (data.requesterName || data.assigneeName) { rows.push(` - Prioridade - ${priorityBadge(data.priority)} - - `) - } - - if (data.requesterName) { - rows.push(` - - Solicitante - ${escapeHtml(data.requesterName)} - - `) - } - - if (data.assigneeName) { - rows.push(` - - Responsável - ${escapeHtml(data.assigneeName)} + + + + ${data.requesterName ? ` + + ` : ""} + ${data.assigneeName ? ` + + ` : ""} + +
+ Solicitante + ${escapeHtml(data.requesterName)} + + Responsavel + ${escapeHtml(data.assigneeName)} +
+ `) } + // Data de criacao if (data.createdAt) { rows.push(` - Criado em - ${formatDate(data.createdAt)} + + + + + + +
Criado em${formatDate(data.createdAt)}
+ `) } return ` - - - - +
- - ${rows.join("")} -
-
+ ${rows.join("")}
` } +// Sistema de estrelas de avaliacao function ratingStars(rateUrl: string): string { const stars: string[] = [] for (let i = 1; i <= 5; i++) { stars.push(` - - + + + + `) } return ` - - - ${stars.join("")} - -
-

Clique em uma estrela para avaliar

+
+ + + ${stars.join("")} + +
+

Clique em uma estrela para avaliar

+
` } +// Divisor +function divider(): string { + return `
` +} + // ============================================ // Template Base // ============================================ @@ -260,7 +353,7 @@ function baseTemplate(content: string, data: TemplateData): string { - ${escapeHtml(data.subject ?? "Notificação")} + ${escapeHtml(data.subject ?? "Notificacao")} + - + - +
- +
- - - @@ -333,14 +429,14 @@ const templates: Record string> = { test: (data) => { return baseTemplate( ` -

+

${escapeHtml(data.title)}

-

+

${escapeHtml(data.message)}

-

- Enviado em: ${escapeHtml(data.timestamp)} +

+ Enviado em ${escapeHtml(data.timestamp)}

`, data @@ -352,11 +448,14 @@ const templates: Record string> = { const viewUrl = data.viewUrl as string return baseTemplate( ` -

+

Chamado aberto

-

- Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve. +

+ Seu chamado foi registrado com sucesso. +

+

+ Nossa equipe ira analisa-lo e entrar em contato em breve.

${ticketInfoCard({ @@ -367,26 +466,32 @@ const templates: Record string> = { createdAt: data.createdAt as string, })} -
- ${button("Ver chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data ) }, - // Resolução de chamado + // Resolucao de chamado ticket_resolved: (data) => { const viewUrl = data.viewUrl as string const rateUrl = data.rateUrl as string return baseTemplate( ` -

+
+
+ +
+
+ +

Chamado resolvido

-

- Seu chamado foi marcado como resolvido. Esperamos que o atendimento tenha sido satisfatório! +

+ Seu chamado foi marcado como resolvido. Esperamos que o atendimento tenha sido satisfatorio!

${ticketInfoCard({ @@ -399,44 +504,46 @@ const templates: Record string> = { ${ data.resolutionSummary ? ` -
-

RESUMO DA RESOLUÇÃO

+
+

Resumo da resolucao

${escapeHtml(data.resolutionSummary)}

` : "" } -
-

Como foi o atendimento?

-

Sua avaliação nos ajuda a melhorar!

+ ${divider()} + +
+

Como foi o atendimento?

+

Sua avaliacao nos ajuda a melhorar!

${ratingStars(rateUrl)}
- ${button("Ver chamado", viewUrl)} + ${buttonSecondary("Ver detalhes", viewUrl)}
`, data ) }, - // Atribuição de chamado + // Atribuicao de chamado ticket_assigned: (data) => { const viewUrl = data.viewUrl as string const isForRequester = data.isForRequester as boolean - const title = isForRequester ? "Agente atribuído ao chamado" : "Novo chamado atribuído" + const title = isForRequester ? "Agente atribuido" : "Novo chamado atribuido" const message = isForRequester - ? `O agente ${escapeHtml(data.assigneeName)} foi atribuído ao seu chamado e em breve entrará em contato.` - : `Um novo chamado foi atribuído a você. Por favor, verifique os detalhes abaixo.` + ? `O agente ${escapeHtml(data.assigneeName)} foi atribuido ao seu chamado e em breve entrara em contato.` + : `Um novo chamado foi atribuido a voce. Por favor, verifique os detalhes abaixo.` return baseTemplate( ` -

+

${title}

-

+

${message}

@@ -449,15 +556,15 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
- ${button("Ver chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data ) }, - // Mudança de status + // Mudanca de status ticket_status: (data) => { const viewUrl = data.viewUrl as string const oldStatus = getStatusStyle(data.oldStatus as string) @@ -465,10 +572,10 @@ const templates: Record string> = { return baseTemplate( ` -

+

Status atualizado

-

+

O status do seu chamado foi alterado.

@@ -477,39 +584,41 @@ const templates: Record string> = { subject: data.subject as string, })} -
+
+ - -
- R + + R - Raven + + Raven
@@ -296,21 +392,21 @@ function baseTemplate(content: string, data: TemplateData): string {
+ ${content}
-

+

+

Este e-mail foi enviado pelo Sistema de Chamados Raven.

- Gerenciar notificações - | - Ajuda + ${textLink("Gerenciar notificacoes", preferencesUrl)} + | + ${textLink("Central de ajuda", helpUrl)}

- - - +
+ + Anterior ${badge(oldStatus.label, oldStatus.bg, oldStatus.color)} + + Atual ${badge(newStatus.label, newStatus.bg, newStatus.color)}
-
- ${button("Ver chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data ) }, - // Novo comentário + // Novo comentario ticket_comment: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( ` -

- Nova atualização no chamado +

+ Nova atualizacao

-

- ${escapeHtml(data.authorName)} adicionou um comentário ao seu chamado. +

+ ${escapeHtml(data.authorName)} adicionou um comentario ao seu chamado.

${ticketInfoCard({ @@ -517,17 +626,18 @@ const templates: Record string> = { subject: data.subject as string, })} -
-

- ${escapeHtml(data.authorName)} • ${formatDate(data.commentedAt as string)} -

-

+

+
+ ${escapeHtml(data.authorName)} + ${formatDate(data.commentedAt as string)} +
+

${escapeHtml(data.commentBody)}

-
- ${button("Ver chamado", viewUrl)} +
+ ${buttonPrimary("Responder", viewUrl)}
`, data @@ -540,89 +650,116 @@ const templates: Record string> = { return baseTemplate( ` -

- Redefinição de senha +
+
+ 🔒 +
+
+ +

+ Redefinicao de senha

-

- Recebemos uma solicitação para redefinir a senha da sua conta. Se você não fez essa solicitação, pode ignorar este e-mail. +

+ Recebemos uma solicitacao para redefinir a senha da sua conta. Se voce nao fez essa solicitacao, pode ignorar este e-mail.

- ${button("Redefinir senha", resetUrl)} + ${buttonPrimary("Redefinir senha", resetUrl)}
-

- Este link expira em 24 horas. Se você não solicitou a redefinição de senha, - pode ignorar este e-mail com segurança. +

+ Este link expira em 24 horas. Se voce nao solicitou a redefinicao de senha, pode ignorar este e-mail com seguranca.

`, data ) }, - // Verificação de e-mail + // Verificacao de e-mail email_verify: (data) => { const verifyUrl = data.verifyUrl as string return baseTemplate( ` -

+
+
+ +
+
+ +

Confirme seu e-mail

-

- Clique no botão abaixo para confirmar seu endereço de e-mail e ativar sua conta. +

+ Clique no botao abaixo para confirmar seu endereco de e-mail e ativar sua conta.

- ${button("Confirmar e-mail", verifyUrl)} + ${buttonPrimary("Confirmar e-mail", verifyUrl)}
-

- Se você não criou uma conta, pode ignorar este e-mail com segurança. +

+ Se voce nao criou uma conta, pode ignorar este e-mail com seguranca.

`, data ) }, - // Convite de usuário + // Convite de usuario invite: (data) => { const inviteUrl = data.inviteUrl as string return baseTemplate( ` -

- Você foi convidado! +
+
+ 🎉 +
+
+ +

+ Voce foi convidado!

-

- ${escapeHtml(data.inviterName)} convidou você para acessar o Sistema de Chamados Raven. +

+ ${escapeHtml(data.inviterName)} convidou voce para acessar o Sistema de Chamados Raven.

-
- - - - - - ${ - data.companyName - ? ` - - - - - ` - : "" - } -
Função${escapeHtml(data.roleName)}
Empresa${escapeHtml(data.companyName)}
-
+ + + + + ${ + data.companyName + ? ` + + + + ` + : "" + } +
+ + + + + +
Funcao${escapeHtml(data.roleName)}
+
+ + + + + +
Empresa${escapeHtml(data.companyName)}
+
- ${button("Aceitar convite", inviteUrl)} + ${buttonPrimary("Aceitar convite", inviteUrl)}
-

- Este convite expira em 7 dias. Se você não esperava este convite, pode ignorá-lo com segurança. +

+ Este convite expira em 7 dias. Se voce nao esperava este convite, pode ignora-lo com seguranca.

`, data @@ -633,32 +770,54 @@ const templates: Record string> = { new_login: (data) => { return baseTemplate( ` -

- Novo acesso detectado -

-

- Detectamos um novo acesso à sua conta. Se foi você, pode ignorar este e-mail. -

- -
- - - - - - - - - - - - - -
Data/Hora${formatDate(data.loginAt as string)}
Dispositivo${escapeHtml(data.userAgent)}
Endereço IP${escapeHtml(data.ipAddress)}
+
+
+ 🔒 +
-

- Se você não reconhece este acesso, recomendamos alterar sua senha imediatamente. +

+ Novo acesso detectado +

+

+ Detectamos um novo acesso a sua conta. Se foi voce, pode ignorar este e-mail. +

+ + + + + + + + + + + +
+ + + + + +
Data/Hora${formatDate(data.loginAt as string)}
+
+ + + + + +
Dispositivo${escapeHtml(data.userAgent)}
+
+ + + + + +
Endereco IP${escapeHtml(data.ipAddress)}
+
+ +

+ Se voce nao reconhece este acesso, recomendamos alterar sua senha imediatamente.

`, data @@ -671,11 +830,20 @@ const templates: Record string> = { return baseTemplate( ` -

+
+
+ +
+
+ +

SLA em risco

-

- O chamado abaixo está próximo de violar o SLA. Ação necessária! +

+ O chamado abaixo esta proximo de violar o SLA. +

+

+ Acao necessaria!

${ticketInfoCard({ @@ -687,17 +855,17 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
-

- Tempo restante: ${escapeHtml(data.timeRemaining)} +

+

+ ${escapeHtml(data.timeRemaining)}

-

+

Prazo: ${formatDate(data.dueAt as string)}

-
- ${button("Ver chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data @@ -710,11 +878,20 @@ const templates: Record string> = { return baseTemplate( ` -

+
+
+ +
+
+ +

SLA violado

-

- O chamado abaixo violou o SLA estabelecido. Atenção urgente necessária! +

+ O chamado abaixo violou o SLA estabelecido. +

+

+ Atencao urgente necessaria!

${ticketInfoCard({ @@ -726,17 +903,17 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
-

- Tempo excedido: ${escapeHtml(data.timeExceeded)} +

+

+ ${escapeHtml(data.timeExceeded)}

-

+

Prazo era: ${formatDate(data.dueAt as string)}

-
- ${button("Ver chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data @@ -745,7 +922,7 @@ const templates: Record string> = { } // ============================================ -// Exportação +// Exportacao // ============================================ /** @@ -754,13 +931,13 @@ const templates: Record string> = { export function renderTemplate(name: TemplateName, data: TemplateData): string { const template = templates[name] if (!template) { - throw new Error(`Template "${name}" não encontrado`) + throw new Error(`Template "${name}" nao encontrado`) } return template(data) } /** - * Retorna a lista de templates disponíveis + * Retorna a lista de templates disponiveis */ export function getAvailableTemplates(): TemplateName[] { return Object.keys(templates) as TemplateName[]