Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
314
app/views/admin/mensagem_templates/edit.html.erb
Normal file
314
app/views/admin/mensagem_templates/edit.html.erb
Normal file
@@ -0,0 +1,314 @@
|
||||
<%# Editor de blocos de UM evento em UM canal.
|
||||
O estado vive num array JS; o HTML dos cards é desenhado a partir dele e
|
||||
serializado num hidden ao salvar. Desenhar a partir do estado (em vez de ler
|
||||
o DOM) é o que faz arrastar, remover e reordenar não perderem conteúdo. %>
|
||||
<% outro_canal = @canal == 'email' ? 'whatsapp' : 'email' %>
|
||||
<div class="space-y-6" id="editor-mensagem"
|
||||
data-preview-url="<%= preview_template_admin_evento_path(@evento, canal: @canal) %>">
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Mensagem — <%= @evento.nome %></h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Canal: <strong class="text-white"><%= @canal == 'email' ? 'E-mail' : 'WhatsApp' %></strong>
|
||||
· Gatilho: <%= @evento.gatilho_label %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to "Editar #{outro_canal == 'email' ? 'e-mail' : 'WhatsApp'}",
|
||||
template_admin_evento_path(@evento, canal: outro_canal), data: { turbo: false },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
|
||||
<%= link_to 'Voltar', admin_eventos_path, data: { turbo: false },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @template.errors.any? %>
|
||||
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<% @template.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: template_admin_evento_path(@evento, canal: @canal), method: :patch,
|
||||
data: { turbo: false }, id: 'form-template' do |f| %>
|
||||
<%= hidden_field_tag :blocos, @template.blocos.to_json, id: 'campo-blocos' %>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[16rem_minmax(0,1fr)_22rem] gap-4">
|
||||
|
||||
<%# ── Paleta ──────────────────────────────────────────── %>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Blocos</p>
|
||||
<p class="text-xs text-gray-500 mb-3">Arraste para a mensagem — ou clique.</p>
|
||||
<div id="paleta" class="space-y-2">
|
||||
<% Notificacao::Blocos::CATALOGO.each do |tipo, meta| %>
|
||||
<div class="cursor-grab select-none bg-[#141414] border border-white/10 rounded-xl px-3 py-2.5 hover:border-orange-500"
|
||||
data-tipo="<%= tipo %>" title="<%= meta[:dica] %>">
|
||||
<span class="text-gray-500 mr-1">⠿</span>
|
||||
<span class="text-sm text-white"><%= meta[:rotulo] %></span>
|
||||
<span class="block text-xs text-gray-500 mt-0.5"><%= meta[:dica] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Variáveis ─────────────────────────────────────── %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Variáveis</p>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Clique num campo da mensagem e depois numa variável para inseri-la ali.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<% @variaveis.each do |nome, meta| %>
|
||||
<button type="button" class="chip-variavel px-2 py-1 rounded-full bg-white/5 border border-white/10 text-xs text-orange-300 hover:border-orange-500"
|
||||
data-variavel="<%= nome %>" title="<%= meta[:descricao] %> (ex.: <%= meta[:amostra] %>)">
|
||||
<%= "{{#{nome}}}" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Mensagem ────────────────────────────────────────── %>
|
||||
<div class="space-y-4">
|
||||
<% if @canal == 'email' %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<label class="block text-gray-400 text-sm mb-1" for="assunto">Assunto</label>
|
||||
<%= text_field_tag :assunto, @template.assunto, id: 'assunto', class: 'campo-editavel w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Mensagem</p>
|
||||
<div id="canvas" class="space-y-3 min-h-[8rem]"></div>
|
||||
<p id="canvas-vazio" class="text-sm text-gray-500 text-center py-8">
|
||||
Arraste um bloco para cá. Sem blocos, o evento continua usando o texto padrão do sistema.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<%= submit_tag 'Salvar mensagem', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<%= check_box_tag :ativo, '1', @template.ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||
Usar esta mensagem
|
||||
</label>
|
||||
<button type="button" id="btn-preview" class="px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500">
|
||||
Atualizar preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Preview ─────────────────────────────────────────── %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 space-y-3">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider">Preview</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Com valores de exemplo, renderizado pelo mesmo código do envio.
|
||||
</p>
|
||||
<p id="preview-assunto" class="text-sm text-white font-semibold break-words"></p>
|
||||
<%# iframe sandbox: o HTML do e-mail é montado pelo nosso renderizador
|
||||
(que escapa tudo), mas exibir dentro de um sandbox sem scripts evita
|
||||
que os estilos do e-mail vazem para o admin — e vice-versa. %>
|
||||
<iframe id="preview-email" class="w-full h-[26rem] rounded-xl bg-white border-0 <%= 'hidden' unless @canal == 'email' %>"
|
||||
sandbox="" title="Preview do e-mail"></iframe>
|
||||
<pre id="preview-whatsapp" class="whitespace-pre-wrap break-words text-sm text-gray-200 bg-[#0b141a] rounded-xl p-4 min-h-[10rem] <%= 'hidden' if @canal == 'email' %>"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var raiz = document.getElementById('editor-mensagem');
|
||||
if (!raiz) return;
|
||||
|
||||
var CATALOGO = <%= raw Notificacao::Blocos::CATALOGO.to_json %>;
|
||||
var CANAL = <%= raw @canal.to_json %>;
|
||||
var campo = document.getElementById('campo-blocos');
|
||||
var canvas = document.getElementById('canvas');
|
||||
var vazio = document.getElementById('canvas-vazio');
|
||||
|
||||
var blocos;
|
||||
try { blocos = JSON.parse(campo.value) || []; } catch (e) { blocos = []; }
|
||||
|
||||
// Último campo focado: é nele que a variável clicada é inserida.
|
||||
var ultimoCampo = null;
|
||||
document.addEventListener('focusin', function (e) {
|
||||
if (e.target.classList && e.target.classList.contains('campo-editavel')) ultimoCampo = e.target;
|
||||
});
|
||||
|
||||
function escapar(t) {
|
||||
return String(t == null ? '' : t)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function blocoVazio(tipo) {
|
||||
var b = { tipo: tipo };
|
||||
var campos = (CATALOGO[tipo] || {}).campos || {};
|
||||
Object.keys(campos).forEach(function (c) { b[c] = campos[c] === 'lista' ? [] : ''; });
|
||||
return b;
|
||||
}
|
||||
|
||||
function campoHtml(indice, chave, especie, valor) {
|
||||
var id = 'b' + indice + '_' + chave;
|
||||
var rotulo = chave.charAt(0).toUpperCase() + chave.slice(1);
|
||||
if (especie === 'multilinha') {
|
||||
return '<label class="block text-xs text-gray-500 mb-1" for="' + id + '">' + rotulo + '</label>' +
|
||||
'<textarea id="' + id + '" rows="3" data-indice="' + indice + '" data-campo="' + chave + '"' +
|
||||
' class="campo-editavel campo-bloco w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||
escapar(valor) + '</textarea>';
|
||||
}
|
||||
return '<label class="block text-xs text-gray-500 mb-1" for="' + id + '">' + rotulo + '</label>' +
|
||||
'<input id="' + id + '" type="text" value="' + escapar(valor) + '" data-indice="' + indice + '" data-campo="' + chave + '"' +
|
||||
' class="campo-editavel campo-bloco w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">';
|
||||
}
|
||||
|
||||
function listaHtml(indice, linhas) {
|
||||
var html = '<p class="text-xs text-gray-500 mb-1">Linhas da tabela</p>';
|
||||
(linhas || []).forEach(function (linha, j) {
|
||||
html += '<div class="flex gap-2 mb-2">' +
|
||||
'<input type="text" value="' + escapar(linha.rotulo) + '" placeholder="Rótulo" data-indice="' + indice + '" data-linha="' + j + '" data-parte="rotulo"' +
|
||||
' class="campo-editavel campo-linha flex-1 bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||
'<input type="text" value="' + escapar(linha.valor) + '" placeholder="Valor" data-indice="' + indice + '" data-linha="' + j + '" data-parte="valor"' +
|
||||
' class="campo-editavel campo-linha flex-1 bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||
'<button type="button" class="btn-remove-linha px-2 text-red-400 hover:text-red-300" data-indice="' + indice + '" data-linha="' + j + '" title="Remover linha">✕</button>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '<button type="button" class="btn-add-linha text-xs text-orange-400 hover:text-orange-300" data-indice="' + indice + '">+ linha</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function desenhar() {
|
||||
canvas.innerHTML = '';
|
||||
vazio.classList.toggle('hidden', blocos.length > 0);
|
||||
|
||||
blocos.forEach(function (bloco, i) {
|
||||
var meta = CATALOGO[bloco.tipo] || { rotulo: bloco.tipo, campos: {} };
|
||||
var card = document.createElement('div');
|
||||
card.className = 'bg-[#141414] border border-white/10 rounded-xl p-3';
|
||||
card.dataset.indice = i;
|
||||
|
||||
var corpo = '<div class="flex items-center justify-between mb-2">' +
|
||||
'<span class="text-sm text-white"><span class="alca cursor-grab text-gray-500 mr-1">⠿</span>' + escapar(meta.rotulo) + '</span>' +
|
||||
'<button type="button" class="btn-remove text-red-400 hover:text-red-300 text-sm" data-indice="' + i + '" title="Remover bloco">✕</button>' +
|
||||
'</div>';
|
||||
|
||||
var campos = meta.campos || {};
|
||||
Object.keys(campos).forEach(function (chave) {
|
||||
corpo += '<div class="mb-2">' +
|
||||
(campos[chave] === 'lista' ? listaHtml(i, bloco[chave]) : campoHtml(i, chave, campos[chave], bloco[chave])) +
|
||||
'</div>';
|
||||
});
|
||||
if (!Object.keys(campos).length) {
|
||||
corpo += '<p class="text-xs text-gray-500">Sem campos — só a linha separadora.</p>';
|
||||
}
|
||||
|
||||
card.innerHTML = corpo;
|
||||
canvas.appendChild(card);
|
||||
});
|
||||
|
||||
campo.value = JSON.stringify(blocos);
|
||||
}
|
||||
|
||||
// ── Edição dos campos ──────────────────────────────────────
|
||||
canvas.addEventListener('input', function (e) {
|
||||
var alvo = e.target;
|
||||
if (alvo.classList.contains('campo-bloco')) {
|
||||
blocos[+alvo.dataset.indice][alvo.dataset.campo] = alvo.value;
|
||||
} else if (alvo.classList.contains('campo-linha')) {
|
||||
var bloco = blocos[+alvo.dataset.indice];
|
||||
bloco.linhas = bloco.linhas || [];
|
||||
bloco.linhas[+alvo.dataset.linha][alvo.dataset.parte] = alvo.value;
|
||||
} else { return; }
|
||||
campo.value = JSON.stringify(blocos); // sem redesenhar: perderia o cursor
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', function (e) {
|
||||
var alvo = e.target;
|
||||
if (alvo.classList.contains('btn-remove')) {
|
||||
blocos.splice(+alvo.dataset.indice, 1); desenhar();
|
||||
} else if (alvo.classList.contains('btn-add-linha')) {
|
||||
var b = blocos[+alvo.dataset.indice];
|
||||
b.linhas = (b.linhas || []).concat([{ rotulo: '', valor: '' }]); desenhar();
|
||||
} else if (alvo.classList.contains('btn-remove-linha')) {
|
||||
blocos[+alvo.dataset.indice].linhas.splice(+alvo.dataset.linha, 1); desenhar();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Paleta: clicar adiciona, arrastar insere na posição ────
|
||||
document.getElementById('paleta').addEventListener('click', function (e) {
|
||||
var item = e.target.closest('[data-tipo]');
|
||||
if (!item) return;
|
||||
blocos.push(blocoVazio(item.dataset.tipo));
|
||||
desenhar();
|
||||
});
|
||||
|
||||
if (window.Sortable) {
|
||||
Sortable.create(document.getElementById('paleta'), {
|
||||
group: { name: 'blocos', pull: 'clone', put: false },
|
||||
sort: false
|
||||
});
|
||||
Sortable.create(canvas, {
|
||||
group: 'blocos',
|
||||
handle: '.alca',
|
||||
animation: 150,
|
||||
onAdd: function (evt) {
|
||||
// O clone da paleta é DOM sem estado: converte em bloco e redesenha.
|
||||
var tipo = evt.item.dataset.tipo;
|
||||
evt.item.parentNode.removeChild(evt.item);
|
||||
blocos.splice(evt.newIndex, 0, blocoVazio(tipo));
|
||||
desenhar();
|
||||
},
|
||||
onUpdate: function (evt) {
|
||||
var movido = blocos.splice(evt.oldIndex, 1)[0];
|
||||
blocos.splice(evt.newIndex, 0, movido);
|
||||
desenhar();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Variáveis: insere no cursor do último campo focado ─────
|
||||
document.querySelectorAll('.chip-variavel').forEach(function (chip) {
|
||||
chip.addEventListener('click', function () {
|
||||
var texto = '{{' + chip.dataset.variavel + '}}';
|
||||
if (!ultimoCampo) { alert('Clique primeiro no campo onde a variável deve entrar.'); return; }
|
||||
var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length;
|
||||
var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini;
|
||||
ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim);
|
||||
ultimoCampo.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ultimoCampo.focus();
|
||||
ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preview: renderizado no servidor, pelo mesmo código do envio ──
|
||||
var token = document.querySelector('meta[name="csrf-token"]');
|
||||
document.getElementById('btn-preview').addEventListener('click', function () {
|
||||
var assunto = document.getElementById('assunto');
|
||||
fetch(raiz.dataset.previewUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': token ? token.content : ''
|
||||
},
|
||||
body: JSON.stringify({ blocos: campo.value, assunto: assunto ? assunto.value : '' })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (dados) {
|
||||
document.getElementById('preview-assunto').textContent = dados.assunto || '';
|
||||
if (CANAL === 'email') {
|
||||
document.getElementById('preview-email').srcdoc = dados.html || '';
|
||||
} else {
|
||||
document.getElementById('preview-whatsapp').textContent = dados.html || '';
|
||||
}
|
||||
})
|
||||
.catch(function () { alert('Não foi possível gerar o preview agora.'); });
|
||||
});
|
||||
|
||||
desenhar();
|
||||
document.getElementById('btn-preview').click();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user