//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() }