feat(email): adota React Email em notificações e automações
This commit is contained in:
parent
58a1ed6b36
commit
4306b0504d
18 changed files with 940 additions and 337 deletions
|
|
@ -14,7 +14,8 @@ import {
|
|||
} from "./automationsEngine"
|
||||
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
|
||||
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
|
||||
import { buildBaseUrl, renderAutomationEmail, type EmailTicketSummary } from "./emailTemplates"
|
||||
import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail"
|
||||
import { buildBaseUrl } from "./url"
|
||||
|
||||
type AutomationEmailTarget = "AUTO" | "PORTAL" | "STAFF"
|
||||
|
||||
|
|
@ -932,7 +933,7 @@ async function applyActions(
|
|||
const to = Array.from(recipientEmails).slice(0, 50)
|
||||
if (to.length === 0) continue
|
||||
|
||||
const ticketSummary: EmailTicketSummary = {
|
||||
const ticketSummary: AutomationEmailProps["ticket"] = {
|
||||
reference: nextTicket.reference ?? 0,
|
||||
subject: nextTicket.subject ?? "",
|
||||
status: nextTicket.status ?? null,
|
||||
|
|
@ -942,13 +943,14 @@ async function applyActions(
|
|||
assigneeName: ((nextTicket.assigneeSnapshot as { name?: string } | undefined)?.name as string | undefined) ?? null,
|
||||
}
|
||||
|
||||
const html = renderAutomationEmail({
|
||||
const emailProps: AutomationEmailProps = {
|
||||
title: subject,
|
||||
message,
|
||||
ticket: ticketSummary,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
})
|
||||
}
|
||||
const html = await renderAutomationEmailHtml(emailProps)
|
||||
|
||||
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
|
||||
to,
|
||||
|
|
|
|||
|
|
@ -1,268 +0,0 @@
|
|||
const COLORS = {
|
||||
primary: "#00e8ff",
|
||||
primaryDark: "#00c4d6",
|
||||
primaryForeground: "#020617",
|
||||
background: "#f8fafc",
|
||||
card: "#ffffff",
|
||||
border: "#e2e8f0",
|
||||
textPrimary: "#0f172a",
|
||||
textSecondary: "#334155",
|
||||
textMuted: "#64748b",
|
||||
statusPending: "#64748b",
|
||||
statusPendingBg: "#f1f5f9",
|
||||
statusProgress: "#0ea5e9",
|
||||
statusProgressBg: "#e0f2fe",
|
||||
statusPaused: "#f59e0b",
|
||||
statusPausedBg: "#fef3c7",
|
||||
statusResolved: "#10b981",
|
||||
statusResolvedBg: "#d1fae5",
|
||||
priorityLow: "#64748b",
|
||||
priorityLowBg: "#f1f5f9",
|
||||
priorityMedium: "#0a4760",
|
||||
priorityMediumBg: "#dff1fb",
|
||||
priorityHigh: "#7d3b05",
|
||||
priorityHighBg: "#fde8d1",
|
||||
priorityUrgent: "#8b0f1c",
|
||||
priorityUrgentBg: "#fbd9dd",
|
||||
}
|
||||
|
||||
export type EmailTicketSummary = {
|
||||
reference: number
|
||||
subject: string
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
companyName?: string | null
|
||||
requesterName?: string | null
|
||||
assigneeName?: string | null
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
if (value === null || value === undefined) return ""
|
||||
const s = String(value)
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function textToHtmlParagraphs(text: string): string {
|
||||
const trimmed = text.replace(/\r\n/g, "\n").trim()
|
||||
if (!trimmed) return ""
|
||||
const paragraphs = trimmed.split(/\n{2,}/g)
|
||||
return paragraphs
|
||||
.map((p) => `<p style="font-size:14px;line-height:1.65;margin:0 0 12px 0;color:${COLORS.textSecondary};">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
|
||||
.join("")
|
||||
}
|
||||
|
||||
function badge(label: string, bg: string, color: string) {
|
||||
return `<span style="display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:600;background:${bg};color:${color};white-space:nowrap;">${escapeHtml(label)}</span>`
|
||||
}
|
||||
|
||||
function statusBadge(statusRaw: string) {
|
||||
const status = statusRaw.trim().toUpperCase()
|
||||
const map: Record<string, { label: string; bg: string; color: string }> = {
|
||||
PENDING: { label: "Pendente", bg: COLORS.statusPendingBg, color: COLORS.statusPending },
|
||||
AWAITING_ATTENDANCE: { label: "Em andamento", bg: COLORS.statusProgressBg, color: COLORS.statusProgress },
|
||||
PAUSED: { label: "Pausado", bg: COLORS.statusPausedBg, color: COLORS.statusPaused },
|
||||
RESOLVED: { label: "Resolvido", bg: COLORS.statusResolvedBg, color: COLORS.statusResolved },
|
||||
}
|
||||
const entry = map[status] ?? { label: statusRaw, bg: COLORS.statusPendingBg, color: COLORS.statusPending }
|
||||
return badge(entry.label, entry.bg, entry.color)
|
||||
}
|
||||
|
||||
function priorityBadge(priorityRaw: string) {
|
||||
const priority = priorityRaw.trim().toUpperCase()
|
||||
const map: Record<string, { label: string; bg: string; color: string }> = {
|
||||
LOW: { label: "Baixa", bg: COLORS.priorityLowBg, color: COLORS.priorityLow },
|
||||
MEDIUM: { label: "Média", bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium },
|
||||
HIGH: { label: "Alta", bg: COLORS.priorityHighBg, color: COLORS.priorityHigh },
|
||||
URGENT: { label: "Urgente", bg: COLORS.priorityUrgentBg, color: COLORS.priorityUrgent },
|
||||
}
|
||||
const entry = map[priority] ?? { label: priorityRaw, bg: COLORS.priorityMediumBg, color: COLORS.priorityMedium }
|
||||
return badge(entry.label, entry.bg, entry.color)
|
||||
}
|
||||
|
||||
export function buildBaseUrl() {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
|
||||
}
|
||||
|
||||
function ticketInfoCard(ticket: EmailTicketSummary) {
|
||||
const rows: string[] = []
|
||||
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Chamado</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;font-weight:700;padding:4px 0;">#${escapeHtml(ticket.reference)}</td>
|
||||
</tr>
|
||||
`)
|
||||
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Assunto</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.subject)}</td>
|
||||
</tr>
|
||||
`)
|
||||
|
||||
if (ticket.companyName) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Empresa</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.companyName)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (ticket.status) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Status</td>
|
||||
<td style="padding:4px 0;">${statusBadge(ticket.status)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (ticket.priority) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Prioridade</td>
|
||||
<td style="padding:4px 0;">${priorityBadge(ticket.priority)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (ticket.requesterName) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Solicitante</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.requesterName)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
if (ticket.assigneeName) {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:12px;padding:4px 10px 4px 0;vertical-align:top;">Responsável</td>
|
||||
<td style="color:${COLORS.textPrimary};font-size:14px;padding:4px 0;">${escapeHtml(ticket.assigneeName)}</td>
|
||||
</tr>
|
||||
`)
|
||||
}
|
||||
|
||||
return `
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};border-radius:10px;margin:18px 0 0 0;">
|
||||
<tr>
|
||||
<td style="padding:16px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
${rows.join("")}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
function button(label: string, url: string) {
|
||||
return `<a href="${escapeHtml(url)}" style="display:inline-block;background:${COLORS.primary};color:${COLORS.primaryForeground};text-decoration:none;border-radius:12px;padding:12px 18px;font-weight:800;font-size:14px;border:1px solid ${COLORS.primaryDark};">${escapeHtml(label)}</a>`
|
||||
}
|
||||
|
||||
export function renderAutomationEmail(params: {
|
||||
title: string
|
||||
message: string
|
||||
ticket: EmailTicketSummary
|
||||
ctaLabel: string
|
||||
ctaUrl: string
|
||||
}) {
|
||||
const baseUrl = buildBaseUrl()
|
||||
|
||||
const content = `
|
||||
<table cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="vertical-align:middle;">
|
||||
<img src="${baseUrl}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:10px;display:block;" />
|
||||
</td>
|
||||
<td style="vertical-align:middle;padding-left:12px;">
|
||||
<span style="font-weight:800;font-size:18px;color:${COLORS.textPrimary};">Raven</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;color:${COLORS.textPrimary};">${escapeHtml(params.title)}</h1>
|
||||
${textToHtmlParagraphs(params.message)}
|
||||
${ticketInfoCard(params.ticket)}
|
||||
<div style="margin-top:18px;">
|
||||
${button(params.ctaLabel, params.ctaUrl)}
|
||||
</div>
|
||||
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:18px;">
|
||||
Se o botão não funcionar, copie e cole esta URL no navegador:<br/>
|
||||
<a href="${escapeHtml(params.ctaUrl)}" style="color:${COLORS.primaryDark};text-decoration:none;">${escapeHtml(params.ctaUrl)}</a>
|
||||
</p>
|
||||
`
|
||||
|
||||
return `
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};padding:28px 0;font-family:Arial,Helvetica,sans-serif;color:${COLORS.textPrimary};">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:16px;padding:24px;max-width:600px;width:100%;">
|
||||
<tr>
|
||||
<td style="text-align:left;">
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:14px;">© ${new Date().getFullYear()} Raven — Rever Tecnologia</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
export function renderSimpleNotificationEmail(params: {
|
||||
title: string
|
||||
message: string
|
||||
ctaLabel: string
|
||||
ctaUrl: string
|
||||
}) {
|
||||
const baseUrl = buildBaseUrl()
|
||||
|
||||
const content = `
|
||||
<table cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="vertical-align:middle;">
|
||||
<img src="${baseUrl}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:10px;display:block;" />
|
||||
</td>
|
||||
<td style="vertical-align:middle;padding-left:12px;">
|
||||
<span style="font-weight:800;font-size:18px;color:${COLORS.textPrimary};">Raven</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;color:${COLORS.textPrimary};">${escapeHtml(params.title)}</h1>
|
||||
${textToHtmlParagraphs(params.message)}
|
||||
<div style="margin-top:18px;">
|
||||
${button(params.ctaLabel, params.ctaUrl)}
|
||||
</div>
|
||||
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:18px;">
|
||||
Se o botão não funcionar, copie e cole esta URL no navegador:<br/>
|
||||
<a href="${escapeHtml(params.ctaUrl)}" style="color:${COLORS.primaryDark};text-decoration:none;">${escapeHtml(params.ctaUrl)}</a>
|
||||
</p>
|
||||
`
|
||||
|
||||
return `
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.background};padding:28px 0;font-family:Arial,Helvetica,sans-serif;color:${COLORS.textPrimary};">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:16px;padding:24px;max-width:600px;width:100%;">
|
||||
<tr>
|
||||
<td style="text-align:left;">
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:${COLORS.textMuted};margin-top:14px;">© ${new Date().getFullYear()} Raven — Rever Tecnologia</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
15
convex/reactEmail.tsx
Normal file
15
convex/reactEmail.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import * as React from "react"
|
||||
import { render } from "@react-email/render"
|
||||
|
||||
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
|
||||
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
|
||||
|
||||
export type { AutomationEmailProps, SimpleNotificationEmailProps }
|
||||
|
||||
export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
||||
return render(<AutomationEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
|
||||
return render(<SimpleNotificationEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import tls from "tls"
|
|||
import { action } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
|
||||
import { buildBaseUrl, renderSimpleNotificationEmail } from "./emailTemplates"
|
||||
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
|
||||
import { buildBaseUrl } from "./url"
|
||||
|
||||
function b64(input: string) {
|
||||
return Buffer.from(input, "utf8").toString("base64")
|
||||
|
|
@ -99,7 +100,7 @@ export const sendPublicCommentEmail = action({
|
|||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||
const html = renderSimpleNotificationEmail({
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Nova atualização no seu chamado #${reference}`,
|
||||
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
|
||||
ctaLabel: "Abrir e responder",
|
||||
|
|
@ -126,7 +127,7 @@ export const sendResolvedEmail = action({
|
|||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
const html = renderSimpleNotificationEmail({
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Chamado #${reference} encerrado`,
|
||||
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
ctaLabel: "Ver detalhes",
|
||||
|
|
|
|||
4
convex/url.ts
Normal file
4
convex/url.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function buildBaseUrl() {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue