/** * Sistema de Templates de E-mail * Sistema de Chamados Raven * Design inspirado em boas práticas de e-mail marketing */ // ============================================ // Tipos // ============================================ export type TemplateName = | "test" | "ticket_created" | "ticket_resolved" | "ticket_assigned" | "ticket_status" | "ticket_comment" | "password_reset" | "email_verify" | "invite" | "new_login" | "sla_warning" | "sla_breached" export type TemplateData = Record // ============================================ // Design Tokens - Sincronizado com globals.css // ============================================ const COLORS = { // Primárias - cyan do sistema primary: "#00e8ff", primaryDark: "#00d6eb", primaryForeground: "#020617", // Backgrounds background: "#f7f8fb", card: "#ffffff", cardAlt: "#f8fafc", // Borders border: "#e2e8f0", borderLight: "#f1f5f9", // Texto textPrimary: "#0f172a", textSecondary: "#475569", textMuted: "#94a3b8", // Status - alinhado com status-badge.tsx statusPending: "#64748b", statusPendingBg: "#f1f5f9", statusProgress: "#0a4760", statusProgressBg: "#dff1fb", statusPaused: "#7a5901", statusPausedBg: "#fff3c4", statusResolved: "#1f6a45", statusResolvedBg: "#dcf4eb", // Prioridade priorityLow: "#64748b", priorityLowBg: "#f1f5f9", priorityMedium: "#0a4760", priorityMediumBg: "#dff1fb", priorityHigh: "#92400e", priorityHighBg: "#fef3c7", priorityUrgent: "#991b1b", priorityUrgentBg: "#fee2e2", // Alertas warning: "#d97706", warningBg: "#fffbeb", error: "#dc2626", errorBg: "#fef2f2", success: "#059669", successBg: "#ecfdf5", // Estrelas starActive: "#f59e0b", starInactive: "#e2e8f0", // Botao secundario buttonSecondary: "#0f172a", buttonSecondaryForeground: "#f8fafc", } // ============================================ // Helpers // ============================================ function escapeHtml(str: unknown): string { if (str === null || str === undefined) return "" const s = String(str) return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } function formatDate(date: Date | string | number, options?: Intl.DateTimeFormatOptions): string { const d = date instanceof Date ? date : new Date(date) return d.toLocaleDateString("pt-BR", { timeZone: "America/Sao_Paulo", day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", ...options, }) } 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 // ============================================ function getStatusStyle(status: string): { bg: string; color: string; label: string } { const statusMap: Record = { PENDING: { bg: COLORS.statusPendingBg, color: COLORS.statusPending, label: "Pendente" }, AWAITING_ATTENDANCE: { bg: COLORS.statusProgressBg, color: COLORS.statusProgress, label: "Em andamento" }, PAUSED: { bg: COLORS.statusPausedBg, color: COLORS.statusPaused, label: "Pausado" }, RESOLVED: { bg: COLORS.statusResolvedBg, color: COLORS.statusResolved, label: "Resolvido" }, } return statusMap[status] ?? { bg: COLORS.statusPendingBg, color: COLORS.statusPending, label: status } } 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" }, 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)}` } function statusBadge(status: string): string { const style = getStatusStyle(status) return badge(style.label, style.bg, style.color) } function priorityBadge(priority: string): string { const style = getPriorityStyle(priority) return badge(style.label, style.bg, style.color) } // 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 informações do ticket function ticketInfoCard(data: { reference: number | string subject: string status?: string priority?: string requesterName?: string assigneeName?: string createdAt?: Date | string }): string { const rows: string[] = [] // Número do chamado com destaque rows.push(`
Chamado #${escapeHtml(data.reference)}
`) // Assunto rows.push(`
Assunto ${escapeHtml(data.subject)}
`) // Status e Prioridade na mesma linha if (data.status || data.priority) { rows.push(` ${data.status ? ` ` : ""} ${data.priority ? ` ` : ""}
Status ${statusBadge(data.status)} Prioridade ${priorityBadge(data.priority)}
`) } // Solicitante e Responsável if (data.requesterName || data.assigneeName) { rows.push(` ${data.requesterName ? ` ` : ""} ${data.assigneeName ? ` ` : ""}
Solicitante ${escapeHtml(data.requesterName)} Responsável ${escapeHtml(data.assigneeName)}
`) } // Data de criação if (data.createdAt) { rows.push(`
Criado em ${formatDate(data.createdAt)}
`) } return ` ${rows.join("")}
` } // Sistema de estrelas de avaliação 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

` } // Divisor function divider(): string { return `
` } // ============================================ // Template Base // ============================================ function baseTemplate(content: string, data: TemplateData): string { const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br" const preferencesUrl = `${appUrl}/settings/notifications` const helpUrl = `${appUrl}/help` return ` ${escapeHtml(data.subject ?? "Notificação")}
R Raven
${content}

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

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

` } // ============================================ // Templates // ============================================ const templates: Record string> = { // Template de teste test: (data) => { return baseTemplate( `

${escapeHtml(data.title)}

${escapeHtml(data.message)}

Enviado em ${escapeHtml(data.timestamp)}

`, data ) }, // Abertura de chamado ticket_created: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( `

Chamado aberto

Seu chamado foi registrado com sucesso.

Nossa equipe irá analisá-lo e entrar em contato em breve.

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, status: data.status as string, priority: data.priority as string, createdAt: data.createdAt as string, })}
${buttonPrimary("Ver chamado", viewUrl)}
`, data ) }, // Resolução 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!

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, status: "RESOLVED", assigneeName: data.assigneeName as string, })} ${ data.resolutionSummary ? `

Resumo da resolução

${escapeHtml(data.resolutionSummary)}

` : "" } ${divider()}

Como foi o atendimento?

Sua avaliação nos ajuda a melhorar!

${ratingStars(rateUrl)}
${buttonSecondary("Ver detalhes", viewUrl)}
`, data ) }, // Atribuição de chamado ticket_assigned: (data) => { const viewUrl = data.viewUrl as string const isForRequester = data.isForRequester as boolean const title = isForRequester ? "Agente atribuído" : "Novo chamado atribuído" 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.` return baseTemplate( `

${title}

${message}

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, status: data.status as string, priority: data.priority as string, requesterName: data.requesterName as string, assigneeName: data.assigneeName as string, })}
${buttonPrimary("Ver chamado", viewUrl)}
`, data ) }, // Mudança de status ticket_status: (data) => { const viewUrl = data.viewUrl as string const oldStatus = getStatusStyle(data.oldStatus as string) const newStatus = getStatusStyle(data.newStatus as string) return baseTemplate( `

Status atualizado

O status do seu chamado foi alterado.

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

Nova atualização

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

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, })}
${escapeHtml(data.authorName)} ${formatDate(data.commentedAt as string)}

${escapeHtml(data.commentBody)}

${buttonPrimary("Responder", viewUrl)}
`, data ) }, // Reset de senha password_reset: (data) => { const resetUrl = data.resetUrl as string return baseTemplate( `
🔒

Redefinição 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.

${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.

`, data ) }, // Verificação 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.

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

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

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

Você foi convidado!

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

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

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

`, data ) }, // Novo login detectado 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.

`, data ) }, // Alerta de SLA em risco sla_warning: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( `

SLA em risco

O chamado abaixo está próximo de violar o SLA.

Ação necessária!

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, status: data.status as string, priority: data.priority as string, requesterName: data.requesterName as string, assigneeName: data.assigneeName as string, })}

${escapeHtml(data.timeRemaining)}

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

${buttonPrimary("Ver chamado", viewUrl)}
`, data ) }, // Alerta de SLA violado sla_breached: (data) => { const viewUrl = data.viewUrl as string return baseTemplate( `

SLA violado

O chamado abaixo violou o SLA estabelecido.

Atenção urgente necessária!

${ticketInfoCard({ reference: data.reference as number, subject: data.subject as string, status: data.status as string, priority: data.priority as string, requesterName: data.requesterName as string, assigneeName: data.assigneeName as string, })}

${escapeHtml(data.timeExceeded)}

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

${buttonPrimary("Ver chamado", viewUrl)}
`, data ) }, } // ============================================ // Exportação // ============================================ /** * Renderiza um template de e-mail com os dados fornecidos */ export function renderTemplate(name: TemplateName, data: TemplateData): string { const template = templates[name] if (!template) { throw new Error(`Template "${name}" não encontrado`) } return template(data) } /** * Retorna a lista de templates disponíveis */ export function getAvailableTemplates(): TemplateName[] { return Object.keys(templates) as TemplateName[] }