e-li-nps/cmd/widgetwasm/main.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()
}