358 lines
10 KiB
Go
358 lines
10 KiB
Go
//go:build js && wasm
|
|
|
|
package main
|
|
|
|
import (
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"syscall/js"
|
|
|
|
"e-li.nps/internal/contratos"
|
|
)
|
|
|
|
// IMPORTANTE (.agent): o backend Go continua sendo a autoridade das regras.
|
|
// Este WASM existe para concentrar regras de negócio do widget (cliente) em Go,
|
|
// mantendo o arquivo JS pequeno e fácil de auditar.
|
|
|
|
var dataISORe = regexp.MustCompile(`^([0-9]{4})-([0-9]{2})-([0-9]{2})$`)
|
|
|
|
type cfgWidget = contratos.ConfigWidget
|
|
|
|
func main() {
|
|
js.Global().Set("__eli_nps_wasm_preflight", js.FuncOf(preflight))
|
|
js.Global().Set("__eli_nps_wasm_decidir", js.FuncOf(decidir))
|
|
js.Global().Set("__eli_nps_wasm_cooldown_ativo", js.FuncOf(cooldownAtivo))
|
|
js.Global().Set("__eli_nps_wasm_set_cooldown", js.FuncOf(setCooldown))
|
|
js.Global().Set("__eli_nps_wasm_painel_init", js.FuncOf(painelInit))
|
|
js.Global().Set("__eli_nps_wasm_ready", true)
|
|
|
|
// Mantém o módulo vivo.
|
|
select {}
|
|
}
|
|
|
|
func painelInit(this js.Value, args []js.Value) any {
|
|
// Painel: persistência de filtros no localStorage.
|
|
// Regras (.agent): lógica no WASM (Go). Aqui é best-effort e fail-open.
|
|
//
|
|
// - Salva: produto selecionado + checkbox "baixas".
|
|
// - Restaura: se a URL não tiver parâmetros e houver valor salvo,
|
|
// redireciona para /painel?produto=...&baixas=1.
|
|
//
|
|
// Importante: não roda fora do /painel.
|
|
loc := js.Global().Get("location")
|
|
if loc.IsUndefined() || loc.IsNull() {
|
|
return nil
|
|
}
|
|
path := loc.Get("pathname").String()
|
|
if path == "" || !strings.HasPrefix(path, "/painel") {
|
|
return nil
|
|
}
|
|
|
|
storage := js.Global().Get("localStorage")
|
|
if storage.IsUndefined() || storage.IsNull() {
|
|
return nil
|
|
}
|
|
|
|
const keyProd = "eli_nps_painel_produto"
|
|
const keyBaixas = "eli_nps_painel_baixas"
|
|
|
|
// Aguarda o DOM estar pronto para conseguirmos acessar o form/option list.
|
|
doc := js.Global().Get("document")
|
|
if doc.IsUndefined() || doc.IsNull() {
|
|
return nil
|
|
}
|
|
|
|
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
// Best-effort: evita exceptions se APIs não existirem.
|
|
defer func() { _ = recover() }()
|
|
|
|
form := doc.Call("querySelector", `form[action="/painel"]`)
|
|
if form.IsUndefined() || form.IsNull() {
|
|
return nil
|
|
}
|
|
|
|
sel := form.Call("querySelector", `select[name="produto"]`)
|
|
chk := form.Call("querySelector", `input[name="baixas"]`)
|
|
|
|
// Helper: verifica se option existe.
|
|
optionExists := func(selectEl js.Value, value string) bool {
|
|
if selectEl.IsUndefined() || selectEl.IsNull() {
|
|
return false
|
|
}
|
|
opts := selectEl.Get("options")
|
|
ln := opts.Get("length").Int()
|
|
for i := 0; i < ln; i++ {
|
|
opt := opts.Index(i)
|
|
if opt.Get("value").String() == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
persist := func() {
|
|
if !sel.IsUndefined() && !sel.IsNull() {
|
|
v := strings.TrimSpace(sel.Get("value").String())
|
|
if v != "" {
|
|
storage.Call("setItem", keyProd, v)
|
|
}
|
|
}
|
|
baixas := "0"
|
|
if !chk.IsUndefined() && !chk.IsNull() && chk.Get("checked").Truthy() {
|
|
baixas = "1"
|
|
}
|
|
storage.Call("setItem", keyBaixas, baixas)
|
|
}
|
|
|
|
// Restaura/redirect apenas se não há query string.
|
|
s := loc.Get("search").String()
|
|
if strings.TrimSpace(s) == "" {
|
|
storedProd := strings.TrimSpace(storage.Call("getItem", keyProd).String())
|
|
storedBaixas := strings.TrimSpace(storage.Call("getItem", keyBaixas).String())
|
|
if storedProd != "" && optionExists(sel, storedProd) {
|
|
q := "?produto=" + url.QueryEscape(storedProd)
|
|
if storedBaixas == "1" {
|
|
q += "&baixas=1"
|
|
}
|
|
loc.Call("replace", path+q)
|
|
return nil
|
|
}
|
|
// Se só baixas estiver setado, aplica também.
|
|
if storedBaixas == "1" {
|
|
loc.Call("replace", path+"?baixas=1")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Liga listeners para persistência.
|
|
onSubmit := js.FuncOf(func(this js.Value, args []js.Value) any { persist(); return nil })
|
|
onChange := js.FuncOf(func(this js.Value, args []js.Value) any { persist(); return nil })
|
|
form.Call("addEventListener", "submit", onSubmit)
|
|
form.Call("addEventListener", "change", onChange)
|
|
|
|
return nil
|
|
})
|
|
|
|
// DOMContentLoaded
|
|
readyState := doc.Get("readyState").String()
|
|
if readyState != "loading" {
|
|
// Se o WASM foi carregado depois do DOM pronto (comum no painel),
|
|
// rodamos imediatamente.
|
|
handler.Invoke()
|
|
} else {
|
|
doc.Call("addEventListener", "DOMContentLoaded", handler)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cooldownAtivo(this js.Value, args []js.Value) any {
|
|
if len(args) < 1 {
|
|
return false
|
|
}
|
|
chave := strings.TrimSpace(args[0].String())
|
|
if chave == "" {
|
|
return false
|
|
}
|
|
|
|
// Best-effort: se der erro, tratamos como não ativo.
|
|
storage := js.Global().Get("localStorage")
|
|
if storage.IsUndefined() || storage.IsNull() {
|
|
return false
|
|
}
|
|
|
|
v := storage.Call("getItem", chave)
|
|
if v.IsNull() || v.IsUndefined() {
|
|
return false
|
|
}
|
|
|
|
parsed := js.Global().Get("JSON").Call("parse", v)
|
|
until := parsed.Get("until")
|
|
if until.IsUndefined() || until.IsNull() {
|
|
return false
|
|
}
|
|
|
|
agora := js.Global().Get("Date").Call("now").Int()
|
|
return int64(agora) < int64(until.Int())
|
|
}
|
|
|
|
func setCooldown(this js.Value, args []js.Value) any {
|
|
if len(args) < 2 {
|
|
return nil
|
|
}
|
|
chave := strings.TrimSpace(args[0].String())
|
|
if chave == "" {
|
|
return nil
|
|
}
|
|
ateMs := int64(args[1].Int())
|
|
if ateMs <= 0 {
|
|
return nil
|
|
}
|
|
|
|
storage := js.Global().Get("localStorage")
|
|
if storage.IsUndefined() || storage.IsNull() {
|
|
return nil
|
|
}
|
|
|
|
obj := js.Global().Get("Object").New()
|
|
obj.Set("until", ateMs)
|
|
json := js.Global().Get("JSON").Call("stringify", obj)
|
|
storage.Call("setItem", chave, json)
|
|
return nil
|
|
}
|
|
|
|
func preflight(this js.Value, args []js.Value) any {
|
|
if len(args) < 1 {
|
|
return map[string]any{"ok": false, "motivo": "sem_cfg"}
|
|
}
|
|
cfg := lerCfg(args[0])
|
|
|
|
// Bloqueio por data mínima.
|
|
if antesDaDataMinima(cfg.DataMinimaAbertura) {
|
|
return map[string]any{"ok": false, "motivo": "antes_da_data_minima"}
|
|
}
|
|
|
|
// controle de exibição: produto + inquilino_codigo + usuario_codigo
|
|
if cfg.ProdutoNome == "" || cfg.InquilinoCodigo == "" || cfg.UsuarioCodigo == "" {
|
|
return map[string]any{"ok": false, "motivo": "contexto_incompleto"}
|
|
}
|
|
|
|
chaveCooldown := chaveCooldown(cfg.ProdutoNome, cfg.InquilinoCodigo, cfg.UsuarioCodigo)
|
|
untilMs := calcularCooldownAteMs(cfg.CooldownHours)
|
|
|
|
// Enviamos exatamente o produto_nome informado.
|
|
payload := map[string]any{
|
|
"produto_nome": cfg.ProdutoNome,
|
|
"inquilino_codigo": cfg.InquilinoCodigo,
|
|
"inquilino_nome": cfg.InquilinoNome,
|
|
"usuario_codigo": cfg.UsuarioCodigo,
|
|
"usuario_nome": cfg.UsuarioNome,
|
|
"usuario_telefone": cfg.UsuarioTelefone,
|
|
"usuario_email": normalizarEmail(cfg.UsuarioEmail),
|
|
}
|
|
|
|
return map[string]any{
|
|
"ok": true,
|
|
"chave_cooldown": chaveCooldown,
|
|
"cooldown_ate_ms": untilMs,
|
|
"payload": payload,
|
|
}
|
|
}
|
|
|
|
func decidir(this js.Value, args []js.Value) any {
|
|
// Entrada:
|
|
// - args[0] = cfg (para cooldownHours)
|
|
// - args[1] = resposta JSON do servidor (obj)
|
|
if len(args) < 2 {
|
|
return map[string]any{"abrir": false, "motivo": "sem_dados"}
|
|
}
|
|
cfg := lerCfg(args[0])
|
|
data := args[1]
|
|
|
|
untilMs := calcularCooldownAteMs(cfg.CooldownHours)
|
|
|
|
// Fail-closed.
|
|
if data.IsUndefined() || data.IsNull() {
|
|
return map[string]any{"abrir": false, "motivo": "resposta_invalida", "aplicar_cooldown": true, "cooldown_ate_ms": untilMs}
|
|
}
|
|
|
|
// Esperado: {pode_abrir:bool, id:string, produto:string}
|
|
podeAbrir := data.Get("pode_abrir")
|
|
if !podeAbrir.Truthy() {
|
|
return map[string]any{"abrir": false, "motivo": "nao_pode_abrir", "aplicar_cooldown": true, "cooldown_ate_ms": untilMs}
|
|
}
|
|
id := strings.TrimSpace(data.Get("id").String())
|
|
produtoRota := strings.TrimSpace(data.Get("produto").String())
|
|
if id == "" || produtoRota == "" {
|
|
// Não dá para montar URL segura.
|
|
return map[string]any{"abrir": false, "motivo": "sem_produto_ou_id", "aplicar_cooldown": true, "cooldown_ate_ms": untilMs}
|
|
}
|
|
|
|
return map[string]any{
|
|
"abrir": true,
|
|
"id": id,
|
|
"produto_rota": produtoRota,
|
|
"aplicar_cooldown": true,
|
|
"cooldown_ate_ms": untilMs,
|
|
}
|
|
}
|
|
|
|
func lerCfg(v js.Value) cfgWidget {
|
|
// Leitura defensiva: tudo é best-effort.
|
|
getStr := func(k string) string {
|
|
if v.Type() != js.TypeObject {
|
|
return ""
|
|
}
|
|
vv := v.Get(k)
|
|
if vv.IsUndefined() || vv.IsNull() {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(vv.String())
|
|
}
|
|
getNum := func(k string) float64 {
|
|
if v.Type() != js.TypeObject {
|
|
return 0
|
|
}
|
|
vv := v.Get(k)
|
|
if vv.IsUndefined() || vv.IsNull() {
|
|
return 0
|
|
}
|
|
return vv.Float()
|
|
}
|
|
|
|
return cfgWidget{
|
|
ProdutoNome: getStr("produto_nome"),
|
|
InquilinoCodigo: getStr("inquilino_codigo"),
|
|
InquilinoNome: getStr("inquilino_nome"),
|
|
UsuarioCodigo: getStr("usuario_codigo"),
|
|
UsuarioNome: getStr("usuario_nome"),
|
|
UsuarioTelefone: getStr("usuario_telefone"),
|
|
UsuarioEmail: getStr("usuario_email"),
|
|
CooldownHours: getNum("cooldownHours"),
|
|
DataMinimaAbertura: getStr("data_minima_abertura"),
|
|
}
|
|
}
|
|
|
|
func normalizarEmail(email string) string {
|
|
return strings.ToLower(strings.TrimSpace(email))
|
|
}
|
|
|
|
func chaveCooldown(produto, inquilino, usuarioCodigo string) string {
|
|
// Prefixo de storage atualizado para o novo nome do projeto.
|
|
return "eli-nps:cooldown:" + produto + ":" + inquilino + ":" + usuarioCodigo
|
|
}
|
|
|
|
func calcularCooldownAteMs(hours float64) int64 {
|
|
if hours <= 0 {
|
|
hours = 24
|
|
}
|
|
agora := js.Global().Get("Date").Call("now").Int()
|
|
return int64(agora) + int64(hours*3600*1000)
|
|
}
|
|
|
|
func antesDaDataMinima(s string) bool {
|
|
// Aceita somente ISO (data) YYYY-MM-DD.
|
|
v := strings.TrimSpace(s)
|
|
if v == "" {
|
|
return false
|
|
}
|
|
m := dataISORe.FindStringSubmatch(v)
|
|
if m == nil {
|
|
return false
|
|
}
|
|
// new Date(ano, mes-1, dia, 0, 0, 0, 0)
|
|
ano := m[1]
|
|
mes := m[2]
|
|
dia := m[3]
|
|
|
|
// Converte para números via JS (simplifica validação e compatibilidade de timezone).
|
|
nAno := js.Global().Get("Number").Invoke(ano).Int()
|
|
nMes := js.Global().Get("Number").Invoke(mes).Int()
|
|
nDia := js.Global().Get("Number").Invoke(dia).Int()
|
|
if nAno <= 0 || nMes < 1 || nMes > 12 || nDia < 1 || nDia > 31 {
|
|
return false
|
|
}
|
|
dataMin := js.Global().Get("Date").New(nAno, nMes-1, nDia, 0, 0, 0, 0)
|
|
agora := js.Global().Get("Date").New()
|
|
return agora.Call("getTime").Int() < dataMin.Call("getTime").Int()
|
|
}
|