e-li-nps/web/static/e-li.nps.js
2026-01-01 19:32:29 -03:00

189 lines
6.5 KiB
JavaScript

(function(){
// Widget NPS (arquivo único).
//
// Regras do projeto (.agent):
// - sem dependências externas
// - fail-closed
// - contratos públicos estáveis
//
// Evolução: regras de negócio do cliente foram movidas para WASM (Go)
// sempre que possível. O backend continua sendo a autoridade.
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 cooldownKey(produto, inquilino, usuarioCodigo){
// Prefixo de storage atualizado para o novo nome do projeto.
return `eli-nps:cooldown:${produto}:${inquilino}:${usuarioCodigo}`;
}
// ------------------------------------------------------------------
// WASM (Go)
// ------------------------------------------------------------------
async function carregarWasm(apiBase){
// fail-closed: se o WASM não carregar, o widget não abre.
if(window.__eli_nps_wasm_ready) return true;
if(window.__eli_nps_wasm_loading) return window.__eli_nps_wasm_loading;
window.__eli_nps_wasm_loading = (async function(){
try{
// wasm_exec.js expõe global `Go`.
if(!window.Go){
await carregarScript(`${apiBase}/static/wasm_exec.js`);
}
const go = new Go();
const res = await fetch(`${apiBase}/static/e-li.nps.wasm`, {cache: 'no-cache'});
if(!res.ok) return false;
const bytes = await res.arrayBuffer();
const {instance} = await WebAssembly.instantiate(bytes, go.importObject);
go.run(instance);
return !!window.__eli_nps_wasm_ready;
}catch(e){
return false;
}
})();
return window.__eli_nps_wasm_loading;
}
function carregarScript(src){
return new Promise(function(resolve, reject){
try{
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = function(){ resolve(); };
s.onerror = function(){ reject(new Error('script_fail')); };
document.head.appendChild(s);
}catch(e){
reject(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 || {});
// Carrega WASM (Go). Sem WASM, não abrimos o widget (fail-closed).
const okWasm = await carregarWasm(cfg.apiBase);
if(!okWasm) return;
// Pré-validação e preparação do payload no WASM.
const pre = window.__eli_nps_wasm_preflight(cfg);
if(!pre || !pre.ok) return;
// Cooldown visual no browser (WASM faz storage best-effort).
// A chave do cooldown é best-effort e não participa de regra de segurança.
if(window.__eli_nps_wasm_cooldown_ativo(pre.chave_cooldown)) return;
let data;
try{
const res = await postJSON(`${cfg.apiBase}/api/e-li.nps/pedido`, pre.payload);
if(!res.ok) return; // fail-closed
data = await res.json();
}catch(e){
return; // fail-closed
}
const dec = window.__eli_nps_wasm_decidir(cfg, data);
if(!dec || !dec.abrir){
// cooldown para evitar flicker se o backend seguir rejeitando.
if(dec && dec.aplicar_cooldown){
window.__eli_nps_wasm_set_cooldown(pre.chave_cooldown, dec.cooldown_ate_ms);
}
return;
}
const modal = createModal();
const iframe = document.createElement('iframe');
iframe.src = `${cfg.apiBase}/e-li.nps/${dec.produto_rota}/${dec.id}/form`;
modal.panel.appendChild(iframe);
// Visual cooldown so it doesn't keep popping (even if user closes).
window.__eli_nps_wasm_set_cooldown(pre.chave_cooldown, dec.cooldown_ate_ms);
}
};
})();