primeira versão do e-li-nps construido com IA

This commit is contained in:
Luiz Silva 2025-12-31 11:18:20 -03:00
commit 06950d6e2c
34 changed files with 2524 additions and 0 deletions

203
web/static/e-li.nps.js Normal file
View file

@ -0,0 +1,203 @@
(function(){
const DEFAULTS = {
apiBase: '',
cooldownHours: 24,
// Data mínima para permitir abertura do modal.
// Formato ISO (data): YYYY-MM-DD (ex.: "2026-01-01").
// Se vazio, não aplica bloqueio por data.
data_minima_abertura: '',
};
function parseDataMinima(s){
// Aceita somente ISO (data) YYYY-MM-DD.
// Retorna um Date no início do dia (00:00) no horário local.
const v = String(s || '').trim();
if(!v) return null;
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(v);
if(!m) return null;
const ano = Number(m[1]);
const mes = Number(m[2]);
const dia = Number(m[3]);
if(!ano || mes < 1 || mes > 12 || dia < 1 || dia > 31) return null;
return new Date(ano, mes-1, dia, 0, 0, 0, 0);
}
function antesDaDataMinima(cfg){
const d = parseDataMinima(cfg.data_minima_abertura);
if(!d) return false;
return new Date() < d;
}
function normalizeEmail(email){
return String(email || '').trim().toLowerCase();
}
function cooldownKey(produto, inquilino, usuarioCodigo){
// Prefixo de storage atualizado para o novo nome do projeto.
return `eli-nps:cooldown:${produto}:${inquilino}:${usuarioCodigo}`;
}
function nowMs(){ return Date.now(); }
function withinCooldown(key){
try{
const v = localStorage.getItem(key);
if(!v) return false;
const obj = JSON.parse(v);
return obj && obj.until && nowMs() < obj.until;
}catch(e){ return false; }
}
function setCooldown(key, hours){
try{
const until = nowMs() + hours*3600*1000;
localStorage.setItem(key, JSON.stringify({until}));
}catch(e){}
}
function createModal(){
const host = document.createElement('div');
host.id = 'eli-nps-host';
const shadow = host.attachShadow ? host.attachShadow({mode:'open'}) : host;
const style = document.createElement('style');
style.textContent = `
.eli-nps-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:2147483647;}
/*
Responsividade do modal do widget:
- Em telas pequenas, usa quase a tela toda.
- Em telas maiores, mantém tamanho máximo confortável.
*/
.eli-nps-panel{width:min(560px, calc(100vw - 24px));height:min(540px, calc(100vh - 24px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 12px 44px rgba(0,0,0,.35);display:flex;flex-direction:column;}
.eli-nps-header{flex:0 0 auto;display:flex;justify-content:flex-end;align-items:center;padding:10px;border-bottom:1px solid #eee;}
.eli-nps-close{border:1px solid #ddd;background:#fff;border-radius:10px;padding:8px 12px;cursor:pointer;font:600 13px system-ui;}
iframe{width:100%;flex:1 1 auto;border:0;}
@media (max-width: 480px){
.eli-nps-panel{width:calc(100vw - 16px);height:calc(100vh - 16px);border-radius:12px;}
.eli-nps-header{padding:8px;}
.eli-nps-close{padding:10px 12px;font-size:14px;}
}
`;
const backdrop = document.createElement('div');
backdrop.className = 'eli-nps-backdrop';
const panel = document.createElement('div');
panel.className = 'eli-nps-panel';
const header = document.createElement('div');
header.className = 'eli-nps-header';
const close = document.createElement('button');
close.className = 'eli-nps-close';
close.textContent = 'Fechar';
header.appendChild(close);
panel.appendChild(header);
backdrop.appendChild(panel);
shadow.appendChild(style);
shadow.appendChild(backdrop);
function destroy(){
try{ host.remove(); }catch(e){}
window.removeEventListener('message', onMsg);
}
function onMsg(ev){
if(ev && ev.data && ev.data.type === 'eli-nps:done'){
destroy();
}
}
close.addEventListener('click', destroy);
// Importante: não fechamos o modal ao clicar fora (backdrop).
// Em mobile é comum tocar fora sem querer e perder o formulário.
window.addEventListener('message', onMsg);
document.body.appendChild(host);
return {shadow, panel, destroy};
}
async function postJSON(url, body){
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
return res;
}
// API pública do widget.
// Nome novo do projeto: e-li.nps
window.ELiNPS = {
init: async function(opts){
const cfg = Object.assign({}, DEFAULTS, opts || {});
// Bloqueio por data mínima (feature flag simples).
// Ex.: não abrir modal antes de 2026-01-01.
if(antesDaDataMinima(cfg)){
return;
}
// produto_nome pode ser qualquer string (ex.: "e-licencie", "Cachaça & Churras").
// Regra do projeto: o tratamento/normalização de caracteres deve ser feito
// apenas no backend, exclusivamente para nome de tabela/rotas.
const produtoNome = String(cfg.produto_nome || '').trim();
const inquilino = String(cfg.inquilino_codigo || '').trim();
const usuarioCodigo = String(cfg.usuario_codigo || '').trim();
const email = normalizeEmail(cfg.usuario_email);
// controle de exibição: produto + inquilino_codigo + usuario_codigo
if(!produtoNome || !inquilino || !usuarioCodigo){
return; // missing required context
}
// A chave do cooldown é “best-effort” e não participa de nenhuma regra
// de segurança. Mantemos o produto como foi informado.
const chaveCooldown = cooldownKey(produtoNome, inquilino, usuarioCodigo);
if(withinCooldown(chaveCooldown)) return;
// Enviamos exatamente o produto_nome informado.
const payload = {
produto_nome: produtoNome,
inquilino_codigo: inquilino,
inquilino_nome: String(cfg.inquilino_nome || '').trim(),
usuario_codigo: usuarioCodigo,
usuario_nome: String(cfg.usuario_nome || '').trim(),
usuario_telefone: String(cfg.usuario_telefone || '').trim(),
usuario_email: email,
};
let data;
try{
const res = await postJSON(`${cfg.apiBase}/api/e-li.nps/pedido`, payload);
if(!res.ok) return; // fail-closed
data = await res.json();
}catch(e){
return; // fail-closed
}
if(!data || !data.pode_abrir || !data.id){
// small cooldown to avoid flicker if backend keeps rejecting
setCooldown(chaveCooldown, cfg.cooldownHours);
return;
}
// Backend can return normalized product; use it for building iframe URL.
const produtoRota = data.produto;
if(!produtoRota){
// fail-closed (não dá pra montar URL segura)
setCooldown(chaveCooldown, cfg.cooldownHours);
return;
}
const modal = createModal();
const iframe = document.createElement('iframe');
iframe.src = `${cfg.apiBase}/e-li.nps/${produtoRota}/${data.id}/form`;
modal.panel.appendChild(iframe);
// Visual cooldown so it doesn't keep popping (even if user closes).
setCooldown(chaveCooldown, cfg.cooldownHours);
}
};
})();

87
web/static/teste.html Normal file
View file

@ -0,0 +1,87 @@
<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-li.nps • Teste</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;}
.card{max-width:760px;margin:0 auto;border:1px solid #e5e5e5;border-radius:12px;padding:16px;}
.row{display:flex;gap:12px;flex-wrap:wrap;}
label{display:block;font-size:12px;color:#444;margin-bottom:6px;}
input{width:280px;max-width:100%;padding:10px;border:1px solid #ddd;border-radius:10px;}
button{padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer;}
code{background:#f6f6f6;padding:2px 6px;border-radius:6px;}
.muted{color:#555;font-size:13px;}
</style>
</head>
<body>
<div class="card">
<h1>e-li.nps • Página de teste</h1>
<p class="muted">
Esta página carrega <code>/static/e-li.nps.js</code> e dispara <code>window.ELiNPS.init()</code>.
Se a API permitir, abrirá o modal (iframe) com o formulário HTMX.
</p>
<div class="row">
<div>
<label>produto_nome</label>
<input id="produto" value="e-licencie.ind" />
</div>
<div>
<label>inquilino_codigo</label>
<input id="inquilino_codigo" value="acme" />
</div>
<div>
<label>inquilino_nome</label>
<input id="inquilino_nome" value="ACME LTDA" />
</div>
<div>
<label>usuario_codigo</label>
<input id="usuario_codigo" value="u-123" />
</div>
<div>
<label>usuario_nome</label>
<input id="usuario_nome" value="Maria" />
</div>
<div>
<label>usuario_telefone</label>
<input id="usuario_telefone" value="+55 11 99999-9999" />
</div>
<div>
<label>usuario_email (opcional)</label>
<input id="usuario_email" value="maria@acme.com" />
</div>
</div>
<p style="margin-top:16px;">
<button id="btn">Abrir NPS</button>
</p>
<p class="muted">
Dica: se você testar repetidamente, pode cair nas regras (45 dias / 10 dias).
Para forçar reaparecer, use outro e-mail ou limpe a tabela do produto no Postgres.
</p>
</div>
<script src="/static/e-li.nps.js"></script>
<script>
function read(id){ return document.getElementById(id).value; }
document.getElementById('btn').addEventListener('click', function(){
window.ELiNPS.init({
apiBase: window.location.origin,
// Bloqueia abertura antes de uma data (YYYY-MM-DD).
// Ex.: "2026-01-01".
// data_minima_abertura: '2026-01-01',
produto_nome: read('produto'),
inquilino_codigo: read('inquilino_codigo'),
inquilino_nome: read('inquilino_nome'),
usuario_codigo: read('usuario_codigo'),
usuario_nome: read('usuario_nome'),
usuario_telefone: read('usuario_telefone'),
usuario_email: read('usuario_email')
});
});
</script>
</body>
</html>

1
web/static/vendor/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

12
web/static/vendor/json-enc.js vendored Normal file
View file

@ -0,0 +1,12 @@
htmx.defineExtension('json-enc', {
onEvent: function (name, evt) {
if (name === "htmx:configRequest") {
evt.detail.headers['Content-Type'] = "application/json";
}
},
encodeParameters : function(xhr, parameters, elt) {
xhr.overrideMimeType('text/json');
return (JSON.stringify(parameters));
}
});