160 lines
6.2 KiB
Ruby
160 lines
6.2 KiB
Ruby
# app/services/notificacao/renderizador.rb
|
|
#
|
|
# Blocos + dados => mensagem pronta. Duas saídas do MESMO template:
|
|
#
|
|
# #texto → WhatsApp (texto puro, com o *negrito* do WhatsApp)
|
|
# #html → e-mail (tabelas com estilo inline, que é o que cliente de e-mail
|
|
# renderiza de forma previsível)
|
|
#
|
|
# ⚠️ TODO texto vindo do editor e TODO valor de variável passa por escape no
|
|
# caminho HTML. O corpo é digitado numa tela e viraria injeção de HTML no
|
|
# e-mail — e o preview usa este mesmo renderizador, então um XSS aqui
|
|
# atingiria primeiro o próprio admin.
|
|
#
|
|
# O preview da tela chama exatamente estes métodos: uma segunda implementação em
|
|
# JavaScript inevitavelmente divergiria do que é enviado de verdade.
|
|
module Notificacao
|
|
class Renderizador
|
|
# Cores fixas: cliente de e-mail não lê CSS externo nem variável de tema.
|
|
LARANJA = '#f97316'.freeze
|
|
ESCURO = '#111111'.freeze
|
|
CINZA = '#666666'.freeze
|
|
|
|
def initialize(blocos, dados = {})
|
|
@blocos = Array(blocos).select { |b| b.is_a?(Hash) && Blocos.valido?(b['tipo']) }
|
|
@dados = (dados || {}).transform_keys(&:to_s)
|
|
end
|
|
|
|
# ── WhatsApp ────────────────────────────────────────────────
|
|
def texto
|
|
@blocos.filter_map { |bloco| texto_do(bloco) }.join("\n\n").strip
|
|
end
|
|
|
|
# ── E-mail ──────────────────────────────────────────────────
|
|
def html
|
|
corpo = @blocos.filter_map { |bloco| html_do(bloco) }.join("\n")
|
|
<<~HTML
|
|
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#{ESCURO};line-height:1.6;max-width:600px;">
|
|
#{corpo}
|
|
</div>
|
|
HTML
|
|
end
|
|
|
|
# Substitui {{variavel}} pelos dados. Variável sem valor vira string vazia —
|
|
# deixar "{{valor}}" cru numa mensagem enviada é pior do que deixar o espaço.
|
|
def interpolar(texto)
|
|
texto.to_s.gsub(/\{\{\s*(\w+)\s*\}\}/) { @dados[Regexp.last_match(1)].to_s }
|
|
end
|
|
|
|
private
|
|
|
|
def escapar(valor) = ERB::Util.html_escape(interpolar(valor))
|
|
|
|
def texto_do(bloco)
|
|
case bloco['tipo']
|
|
when 'cabecalho'
|
|
[negrito(bloco['titulo']), interpolar(bloco['subtitulo'])].reject(&:blank?).join("\n").presence
|
|
when 'texto', 'rodape'
|
|
interpolar(bloco['texto']).presence
|
|
when 'aviso'
|
|
conteudo = interpolar(bloco['texto'])
|
|
conteudo.present? ? "⚠️ #{conteudo}" : nil
|
|
when 'tabela'
|
|
linhas = Array(bloco['linhas']).filter_map do |linha|
|
|
next unless linha.is_a?(Hash)
|
|
rotulo = interpolar(linha['rotulo'])
|
|
valor = interpolar(linha['valor'])
|
|
next if rotulo.blank? && valor.blank?
|
|
"• #{rotulo}: #{valor}".strip
|
|
end
|
|
linhas.any? ? linhas.join("\n") : nil
|
|
when 'botao'
|
|
url = interpolar(bloco['url'])
|
|
return nil if url.blank?
|
|
[interpolar(bloco['rotulo']).presence, url].compact.join(': ')
|
|
when 'divisor'
|
|
'——————————'
|
|
end
|
|
end
|
|
|
|
def negrito(valor)
|
|
conteudo = interpolar(valor)
|
|
conteudo.present? ? "*#{conteudo}*" : ''
|
|
end
|
|
|
|
def html_do(bloco)
|
|
case bloco['tipo']
|
|
when 'cabecalho' then html_cabecalho(bloco)
|
|
when 'texto' then html_paragrafo(bloco['texto'])
|
|
when 'aviso' then html_aviso(bloco)
|
|
when 'tabela' then html_tabela(bloco)
|
|
when 'botao' then html_botao(bloco)
|
|
when 'divisor' then %(<hr style="border:none;border-top:1px solid #e5e5e5;margin:20px 0;">)
|
|
when 'rodape' then html_rodape(bloco)
|
|
end
|
|
end
|
|
|
|
def html_cabecalho(bloco)
|
|
titulo = escapar(bloco['titulo'])
|
|
sub = escapar(bloco['subtitulo'])
|
|
return nil if titulo.blank? && sub.blank?
|
|
|
|
partes = []
|
|
partes << %(<h1 style="margin:0 0 4px;font-size:20px;color:#{ESCURO};">#{titulo}</h1>) if titulo.present?
|
|
partes << %(<p style="margin:0 0 16px;font-size:14px;color:#{CINZA};">#{sub}</p>) if sub.present?
|
|
partes.join("\n")
|
|
end
|
|
|
|
# `simple_format` não serve aqui: ele produz HTML sem os estilos inline que o
|
|
# cliente de e-mail precisa. As quebras de linha viram <br>, já escapadas.
|
|
def html_paragrafo(valor)
|
|
conteudo = escapar(valor)
|
|
return nil if conteudo.blank?
|
|
|
|
%(<p style="margin:0 0 14px;">#{conteudo.gsub("\n", '<br>')}</p>)
|
|
end
|
|
|
|
def html_aviso(bloco)
|
|
conteudo = escapar(bloco['texto'])
|
|
return nil if conteudo.blank?
|
|
|
|
%(<div style="margin:0 0 16px;padding:12px 14px;background:#fff8e1;border-left:4px solid #f0ad4e;) +
|
|
%(font-size:14px;color:#5c4400;">#{conteudo.gsub("\n", '<br>')}</div>)
|
|
end
|
|
|
|
def html_tabela(bloco)
|
|
linhas = Array(bloco['linhas']).filter_map do |linha|
|
|
next unless linha.is_a?(Hash)
|
|
rotulo = escapar(linha['rotulo'])
|
|
valor = escapar(linha['valor'])
|
|
next if rotulo.blank? && valor.blank?
|
|
|
|
%(<tr><td style="padding:8px 12px;border-bottom:1px solid #eee;color:#{CINZA};">#{rotulo}</td>) +
|
|
%(<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold;">#{valor}</td></tr>)
|
|
end
|
|
return nil if linhas.empty?
|
|
|
|
%(<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin:0 0 16px;font-size:14px;">) +
|
|
linhas.join + '</table>'
|
|
end
|
|
|
|
def html_botao(bloco)
|
|
url = interpolar(bloco['url']).to_s.strip
|
|
# Só http(s): `javascript:` num href montado na tela seria clique armado.
|
|
return nil unless url.match?(%r{\Ahttps?://}i)
|
|
|
|
rotulo = escapar(bloco['rotulo']).presence || 'Abrir'
|
|
%(<p style="margin:0 0 18px;"><a href="#{ERB::Util.html_escape(url)}" ) +
|
|
%(style="display:inline-block;padding:11px 20px;background:#{LARANJA};color:#000;) +
|
|
%(text-decoration:none;border-radius:8px;font-weight:bold;font-size:14px;">#{rotulo}</a></p>)
|
|
end
|
|
|
|
def html_rodape(bloco)
|
|
conteudo = escapar(bloco['texto'])
|
|
return nil if conteudo.blank?
|
|
|
|
%(<p style="margin:24px 0 0;font-size:12px;color:#999;">#{conteudo.gsub("\n", '<br>')}</p>)
|
|
end
|
|
end
|
|
end
|