From 3ce7254dc7855103bc6d5a3c3f8be981c38bfc82 Mon Sep 17 00:00:00 2001 From: deploy Date: Tue, 6 Oct 2026 19:47:24 +0000 Subject: [PATCH] chore(tenants): conteudo de publico/ atualizado --- moldes/consolidado/96-publico-configmap.yaml | 40 +- tenants/acme/96-publico-configmap.yaml | 573 ++++++++++++++++-- tenants/demo/96-publico-configmap.yaml | 573 ++++++++++++++++-- .../96-publico-configmap.yaml | 573 ++++++++++++++++-- .../96-publico-configmap.yaml | 573 ++++++++++++++++-- tenants/escolinha/96-publico-configmap.yaml | 573 ++++++++++++++++-- tenants/piloto/96-publico-configmap.yaml | 573 ++++++++++++++++-- tenants/plataforma/96-publico-configmap.yaml | 40 +- tenants/teste5/96-publico-configmap.yaml | 573 ++++++++++++++++-- tenants/zico-0/96-publico-configmap.yaml | 573 ++++++++++++++++-- 10 files changed, 4286 insertions(+), 378 deletions(-) diff --git a/moldes/consolidado/96-publico-configmap.yaml b/moldes/consolidado/96-publico-configmap.yaml index e6161e7..629e747 100644 --- a/moldes/consolidado/96-publico-configmap.yaml +++ b/moldes/consolidado/96-publico-configmap.yaml @@ -3756,7 +3756,7 @@ data: - + @@ -4238,7 +4238,8 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + (c.tenant ? '' : "") + + (c.tenant ? '' + + '' : "") + '

' + (alertas ? '" : "") @@ -4760,20 +4761,31 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); - /* ── licenca do app do treinador ────────────────────────────────────────── - * Reenviar = gerar uma chave NOVA e mandar ao dono da compra. A anterior - * deixa de valer para ATIVAR (quem ja ativou o app continua entrando: o app - * guarda a escolinha, nao a chave). Por isso pede confirmacao. */ + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ var API_LICENCA = "/api/plataforma/painel/licencas/"; - function reenviarLicenca(tenant, aviso) { + function acaoDaLicenca(tenant, acao, aviso) { if (!tokenAtual) return Promise.resolve(); - if (!window.confirm("Gerar uma chave NOVA para " + tenant + " e mandar por e-mail ao dono da compra?\n\n" - + "A chave atual deixa de valer para ativar o app (quem já ativou continua entrando).")) { - return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } } aviso.textContent = "Enviando…"; - return fetch(API_LICENCA + encodeURIComponent(tenant) + "/reenviar", { + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" }).then(function (r) { if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); @@ -4782,7 +4794,8 @@ data: return d; }); }).then(function (d) { - aviso.textContent = (d.emailEnviado ? "Chave nova enviada para " : "Chave nova criada; o e-mail vai em instantes para ") + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; }).catch(function (e) { aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; @@ -4793,7 +4806,8 @@ data: var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; if (!b) return; b.disabled = true; - reenviarLicenca(b.getAttribute("data-licenca"), $("licencaAviso")).then(function () { b.disabled = false; }); + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); }); $("fecharFicha").addEventListener("click", fecharFicha); diff --git a/tenants/acme/96-publico-configmap.yaml b/tenants/acme/96-publico-configmap.yaml index fb6bd14..2740d0e 100644 --- a/tenants/acme/96-publico-configmap.yaml +++ b/tenants/acme/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/demo/96-publico-configmap.yaml b/tenants/demo/96-publico-configmap.yaml index 5dd5dcd..caa3b77 100644 --- a/tenants/demo/96-publico-configmap.yaml +++ b/tenants/demo/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +

+ Preencha os dados do seu time e dos jogadores (só o nome e o número da camisa). + A organização confere e aprova antes do time entrar na competição. +

+ +
+ Seu time + + + +
+ +
+ Responsável pelo time + + + +
+ +
+ Jogadores +
+ +
+ + + + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
VencimentoPago emFormaValorLíquidoSituação
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/escolinha-de-teste-2/96-publico-configmap.yaml b/tenants/escolinha-de-teste-2/96-publico-configmap.yaml index 8aac316..4317238 100644 --- a/tenants/escolinha-de-teste-2/96-publico-configmap.yaml +++ b/tenants/escolinha-de-teste-2/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +

+ Preencha os dados do seu time e dos jogadores (só o nome e o número da camisa). + A organização confere e aprova antes do time entrar na competição. +

+ +
+ Seu time + + + +
+ +
+ Responsável pelo time + + + +
+ +
+ Jogadores +
+ +
+ + + + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
VencimentoPago emFormaValorLíquidoSituação
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/escolinha-de-teste/96-publico-configmap.yaml b/tenants/escolinha-de-teste/96-publico-configmap.yaml index 46b2eb1..3788013 100644 --- a/tenants/escolinha-de-teste/96-publico-configmap.yaml +++ b/tenants/escolinha-de-teste/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +

+ Preencha os dados do seu time e dos jogadores (só o nome e o número da camisa). + A organização confere e aprova antes do time entrar na competição. +

+ +
+ Seu time + + + +
+ +
+ Responsável pelo time + + + +
+ +
+ Jogadores +
+ +
+ + + + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
VencimentoPago emFormaValorLíquidoSituação
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/escolinha/96-publico-configmap.yaml b/tenants/escolinha/96-publico-configmap.yaml index 3a1d7b5..28cd64a 100644 --- a/tenants/escolinha/96-publico-configmap.yaml +++ b/tenants/escolinha/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +

+ Preencha os dados do seu time e dos jogadores (só o nome e o número da camisa). + A organização confere e aprova antes do time entrar na competição. +

+ +
+ Seu time + + + +
+ +
+ Responsável pelo time + + + +
+ +
+ Jogadores +
+ +
+ + + + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
VencimentoPago emFormaValorLíquidoSituação
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/piloto/96-publico-configmap.yaml b/tenants/piloto/96-publico-configmap.yaml index c654ef8..e3effda 100644 --- a/tenants/piloto/96-publico-configmap.yaml +++ b/tenants/piloto/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +

+ Preencha os dados do seu time e dos jogadores (só o nome e o número da camisa). + A organização confere e aprova antes do time entrar na competição. +

+ +
+ Seu time + + + +
+ +
+ Responsável pelo time + + + +
+ +
+ Jogadores +
+ +
+ + + + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
VencimentoPago emFormaValorLíquidoSituação
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/plataforma/96-publico-configmap.yaml b/tenants/plataforma/96-publico-configmap.yaml index 5dc77f4..f6ab5e0 100644 --- a/tenants/plataforma/96-publico-configmap.yaml +++ b/tenants/plataforma/96-publico-configmap.yaml @@ -3756,7 +3756,7 @@ data: - + @@ -4238,7 +4238,8 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + (c.tenant ? '' : "") + + (c.tenant ? '' + + '' : "") + '

' + (alertas ? '
    ' + alertas + "
" : "") @@ -4760,20 +4761,31 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); - /* ── licenca do app do treinador ────────────────────────────────────────── - * Reenviar = gerar uma chave NOVA e mandar ao dono da compra. A anterior - * deixa de valer para ATIVAR (quem ja ativou o app continua entrando: o app - * guarda a escolinha, nao a chave). Por isso pede confirmacao. */ + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ var API_LICENCA = "/api/plataforma/painel/licencas/"; - function reenviarLicenca(tenant, aviso) { + function acaoDaLicenca(tenant, acao, aviso) { if (!tokenAtual) return Promise.resolve(); - if (!window.confirm("Gerar uma chave NOVA para " + tenant + " e mandar por e-mail ao dono da compra?\n\n" - + "A chave atual deixa de valer para ativar o app (quem já ativou continua entrando).")) { - return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } } aviso.textContent = "Enviando…"; - return fetch(API_LICENCA + encodeURIComponent(tenant) + "/reenviar", { + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" }).then(function (r) { if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); @@ -4782,7 +4794,8 @@ data: return d; }); }).then(function (d) { - aviso.textContent = (d.emailEnviado ? "Chave nova enviada para " : "Chave nova criada; o e-mail vai em instantes para ") + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; }).catch(function (e) { aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; @@ -4793,7 +4806,8 @@ data: var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; if (!b) return; b.disabled = true; - reenviarLicenca(b.getAttribute("data-licenca"), $("licencaAviso")).then(function () { b.disabled = false; }); + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); }); $("fecharFicha").addEventListener("click", fecharFicha); diff --git a/tenants/teste5/96-publico-configmap.yaml b/tenants/teste5/96-publico-configmap.yaml index 6330cce..4945e8b 100644 --- a/tenants/teste5/96-publico-configmap.yaml +++ b/tenants/teste5/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +

+ Preencha os dados do seu time e dos jogadores (só o nome e o número da camisa). + A organização confere e aprova antes do time entrar na competição. +

+ +
+ Seu time + + + +
+ +
+ Responsável pelo time + + + +
+ +
+ Jogadores +
+ +
+ + + + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
VencimentoPago emFormaValorLíquidoSituação
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/zico-0/96-publico-configmap.yaml b/tenants/zico-0/96-publico-configmap.yaml index 494bb51..3ea2405 100644 --- a/tenants/zico-0/96-publico-configmap.yaml +++ b/tenants/zico-0/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +

+ Preencha os dados do seu time e dos jogadores (só o nome e o número da camisa). + A organização confere e aprova antes do time entrar na competição. +

+ +
+ Seu time + + + +
+ +
+ Responsável pelo time + + + +
+ +
+ Jogadores +
+ +
+ + + + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -609,9 +935,7 @@ data: /* marca */ .logo{display:inline-flex; align-items:center; gap:11px; font-weight:700; font-size:1.22rem; letter-spacing:-.02em; color:var(--ink); text-decoration:none;} - .mark{display:inline-flex; gap:3px; align-items:flex-end;} - .mark i{display:block; width:5px; border-radius:2px; background:var(--laranja); transform:skewX(-14deg);} - .mark i:nth-child(1){height:13px} .mark i:nth-child(2){height:19px} .mark i:nth-child(3){height:25px} + .logo img{display:block; height:37px; width:auto;} .topbar{background:var(--surface); border-bottom:1px solid var(--line);} .topbar .wrap{display:flex; align-items:center; justify-content:space-between; gap:16px; padding-block:14px;} @@ -814,7 +1138,7 @@ data:
@@ -1698,6 +2022,20 @@ data: .termos-uso-caixa input { margin-right: 6px; } .termos-uso-aceite { margin: 10px 0; } + /* ── Abas (2026-09-29) — mesmo desenho do painel do dono ───────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + .aba .badge { + display: inline-block; min-width: 8px; height: 8px; margin-left: 6px; border-radius: 999px; + background: var(--orange); vertical-align: middle; + } + .aba .badge.num { min-width: 18px; height: auto; padding: 0 6px; font-size: 12px; color: #fff; line-height: 18px; } + minha-conta.html: | @@ -1722,7 +2060,7 @@ data: numero a cada publicacao que mexa nesses arquivos. --> - + @@ -1765,23 +2103,56 @@ data:
- -
-

Visão geral

-

-
-
+ + -
+
+
-
-

Nenhum produto ainda

-

- Se você acabou de pagar, a conta pode estar sendo criada — isso leva - alguns minutos. Se já faz mais tempo, fale com a gente. -

+
+

Nenhum produto ainda

+

+ Se você acabou de pagar, a conta pode estar sendo criada — isso leva + alguns minutos. Se já faz mais tempo, fale com a gente. +

+
+
+ +
+ +
+

Visão geral

+

+
+
+
+

Visão geral

+

Os números aparecem aqui quando as suas escolinhas enviarem o primeiro resumo + (uma vez por dia, de madrugada).

+
+
+ +
+
+
+

Termos de Uso

+

Nada pendente: a versão vigente já foi aceita para todas as suas escolinhas. + Ler os Termos de Uso.

+
+
+ +
+

Assinaturas canceladas. Regularize o pagamento para voltar a entrar; os dados ficam + guardados por 30 dias depois do cancelamento.

+

@@ -1801,7 +2172,7 @@ data:

© 2026 Athletic Map. Todos os direitos reservados.
- + @@ -1870,6 +2241,36 @@ data: var erroTexto = document.getElementById("erroTexto"); var botaoSair = document.getElementById("sair"); + /* ── ABAS (2026-09-29) ──────────────────────────────────────────────────────── + * Minhas escolas | Visao geral | Termos de Uso | Canceladas. A ultima aba aberta + * fica no localStorage (conveniencia deste navegador; sem ela, "escolas"). */ + var ABA_CHAVE = "atm.conta.aba"; + function abrirAba(nome) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao || botao.classList.contains("oculto")) { nome = "escolas"; botao = document.querySelector('.aba[data-aba="escolas"]'); } + if (!botao) return; + document.querySelectorAll(".aba").forEach(function (b) { b.classList.toggle("ativa", b === botao); }); + document.querySelectorAll(".painel-aba").forEach(function (painel) { + painel.classList.toggle("oculto", painel.id !== "aba-" + nome); + }); + try { localStorage.setItem(ABA_CHAVE, nome); } catch (e) { /* aba privada */ } + } + function abaLembrada() { try { return localStorage.getItem(ABA_CHAVE) || "escolas"; } catch (e) { return "escolas"; } } + /* Marca a aba: `pendente` acende a bolinha; `quantos` escreve o numero. */ + function marcarAba(nome, pendente, quantos) { + var botao = document.querySelector('.aba[data-aba="' + nome + '"]'); + if (!botao) return; + var badge = botao.querySelector(".badge"); + if (badge) { + badge.classList.toggle("oculto", !pendente && !quantos); + badge.classList.toggle("num", !!quantos); + badge.textContent = quantos ? String(quantos) : ""; + } + } + document.querySelectorAll(".aba").forEach(function (b) { + b.addEventListener("click", function () { abrirAba(b.getAttribute("data-aba")); }); + }); + function mostrar(qual) { [porta, carregando, conteudo, caixaErro].forEach(function (s) { if (s) s.classList.add("oculto"); @@ -1988,14 +2389,18 @@ data: return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; } + /* Tenant de DEMONSTRACAO do dono: o servidor marca (`demonstracao: true`). O + cartao diz isso ao lado da situacao, e nao ganha "Trocar de plano" nem + "Modulos avulsos" — nao ha cobranca, e o servidor recusaria de qualquer jeito. */ function cartao(p) { var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; var podeEntrar = p.endereco && p.situacao === "ATIVA"; - return '
" + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + "

" + escapar(p.escolinha || "Sua escolinha") + "

" + '' + s[1] + "" + + (p.demonstracao ? 'demonstração — sem cobrança' : "") + (podeEntrar ? 'Entrar →' : '

' + (p.situacao === "PREPARANDO" @@ -2014,10 +2419,22 @@ data: .then(function (lista) { enderecos = {}; (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); - produtos.innerHTML = (lista || []).map(cartao).join(""); - nenhum.classList.toggle("oculto", (lista || []).length > 0); + var ativas = (lista || []).filter(function (p) { return p.situacao !== "CANCELADA"; }); + var canceladas = (lista || []).filter(function (p) { return p.situacao === "CANCELADA"; }); + produtos.innerHTML = ativas.map(cartao).join(""); + var caixaCanceladas = document.getElementById("produtosCancelados"); + if (caixaCanceladas) caixaCanceladas.innerHTML = canceladas.map(cartao).join(""); + var abaCanceladas = document.getElementById("abaCanceladas"); + if (abaCanceladas) { + abaCanceladas.classList.toggle("oculto", canceladas.length === 0); + abaCanceladas.textContent = "Canceladas" + (canceladas.length ? " (" + canceladas.length + ")" : ""); + } + nenhum.classList.toggle("oculto", ativas.length > 0); mostrar(conteudo); - (lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); }); + abrirAba(abaLembrada()); + (lista || []).forEach(function (p) { + if (p.tenant && p.situacao === "ATIVA" && !p.demonstracao) acrescentarBotaoDePlano(p.tenant); + }); return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(), carregarTermosDeUso()]); }); @@ -2323,8 +2740,10 @@ data: .then(function (d) { var velho = document.getElementById("avisoTermos"); if (velho) velho.parentNode.removeChild(velho); - if (!d || !d.versao || !d.escolinhas) return; - var faltam = d.escolinhas.filter(function (e) { return !e.aceito; }); + var emDia = document.getElementById("termosEmDia"); + var faltam = (d && d.versao && d.escolinhas) ? d.escolinhas.filter(function (e) { return !e.aceito; }) : []; + if (emDia) emDia.classList.toggle("oculto", faltam.length > 0); + marcarAba("termos", faltam.length > 0); if (!faltam.length) return; var aviso = document.createElement("section"); aviso.id = "avisoTermos"; @@ -2335,7 +2754,8 @@ data: + ". Você continua usando o sistema, vendo faturas e dados normalmente; só trocar de plano" + " e comprar módulos pedem o aceite.

" + caixaDeAceite(d.versao, "Aceitar"); - produtos.parentNode.insertBefore(aviso, produtos); + var caixaTermos = document.getElementById("termosDeUso"); + if (caixaTermos) caixaTermos.appendChild(aviso); else produtos.parentNode.insertBefore(aviso, produtos); aviso.querySelector("button").addEventListener("click", function () { aceitarTermosDeUso(aviso, d.versao); }); @@ -2588,7 +3008,9 @@ data: .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var tabela = d ? blocoDaVisao(d) : null; - if (!tabela) { caixa.classList.add("oculto"); return; } + var vazia = document.getElementById("visaoVazia"); + if (!tabela) { caixa.classList.add("oculto"); if (vazia) vazia.classList.remove("oculto"); return; } + if (vazia) vazia.classList.add("oculto"); var semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length; document.getElementById("visaoSub").innerHTML = "Números de " + nomeDoMes(d.competencia) + ". Só totais — nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia."; @@ -3171,7 +3593,7 @@ data: - +
VencimentoPago emFormaValorLíquidoSituação
@@ -3334,7 +3756,7 @@ data: - + @@ -3630,9 +4052,13 @@ data: bloco("Recebido desde o início", reaisCurto(i.recebidoTotal), i.clientesSemDadoDoAsaas > 0 ? i.clientesSemDadoDoAsaas + " sem resposta do Asaas" : "extrato do Asaas", "") ].join(""); + // Teste e demonstracao aparecem na lista, mas nao nos numeros — e a tela diz. + var foraDosNumeros = []; + if (i.contasDeTeste > 0) foraDosNumeros.push(i.contasDeTeste + " de teste"); + if (i.demonstracoes > 0) foraDosNumeros.push(i.demonstracoes + " de demonstração"); $("kpiClientes").innerHTML = [ bloco("Clientes ativos", esc(i.clientesAtivos), - i.contasDeTeste > 0 ? "+ " + i.contasDeTeste + " de teste (fora dos números)" : "", "ok"), + foraDosNumeros.length ? "+ " + foraDosNumeros.join(", ") + " (fora dos números)" : "", "ok"), bloco("Novos no mês", esc(i.novosNoMes), comparacao(i.novosNoMes, i.novosMesAnterior), ""), bloco("Leads no mês", esc(i.leadsNoMes), comparacao(i.leadsNoMes, i.leadsMesAnterior), ""), bloco("Conversão", pct(i.conversao), "de lead para cliente pagante", ""), @@ -3671,7 +4097,7 @@ data: if (plano && c.plano !== plano) return false; if (estado && (c.estado || "NASCENDO") !== estado) return false; if (soAtencao && !(c.alertas || []).length) return false; - if (semTeste && c.teste) return false; + if (semTeste && (c.teste || c.demonstracao)) return false; if (busca) { var alvo = [c.cliente, c.escolinha, c.email, c.cidade, c.tenant, c.whatsapp, c.documento] .join(" ").toLowerCase(); @@ -3701,7 +4127,9 @@ data: } var SITUACAO_ASAAS = { ACTIVE: "ativa", INACTIVE: "encerrada", EXPIRED: "expirada" }; - function assinaturaNoAsaas(a) { + /* A demonstracao nao existe no Asaas DE PROPOSITO: nao e "nao existe" em vermelho. */ + function assinaturaNoAsaas(a, c) { + if (c && c.demonstracao) return "demonstração — sem cobrança"; if (!a || !a.disponivel) return "indisponível agora"; if (!a.existe) return 'não existe'; var s = SITUACAO_ASAAS[a.situacao] || txt(a.situacao); @@ -3738,10 +4166,11 @@ data: var gravidade = (c.alertas || []).reduce(function (g, a) { return a.gravidade === "critico" ? "critico" : (g === "critico" ? g : (a.gravidade === "alerta" ? "alerta" : g)); }, ""); - return '' + "" + "" - + '" - + '" - + "" + + '" + + '" + + "" + "" + "" + ""; @@ -3778,7 +4207,9 @@ data: + esc(a.titulo) + '' + esc(a.detalhe) + ""; }).join(""); - var extrato = f.disponivel + var extrato = c.demonstracao + ? '

Tenant de demonstração: não há assinatura nem cobrança no Asaas.

' + : f.disponivel ? ((f.cobrancas || []).length ? '
" + txt(c.cliente) + "" + (c.teste ? ' teste' : "") + + (c.demonstracao ? ' demonstração' : "") + '' + (c.tenant ? '' + esc(c.tenant) + "" : "sem tenant") @@ -3750,9 +4179,9 @@ data: + "" + etiquetaPlano(c) + "" + selo(c.estado, c.tenant) + '' + (c.diasDeCliente !== null && c.diasDeCliente !== undefined ? "cliente há " + esc(c.diasDeCliente) + " dia(s)" : "") + "' + reais(c.valorMensal) + diferencaDeTabela(c) + "' + (f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + proximaCobranca(f) + "' + (c.demonstracao ? 'sem cobrança' : reais(c.valorMensal) + diferencaDeTabela(c)) + "' + (c.demonstracao ? "—" : f.disponivel ? reais(f.totalPago) + '' + esc(f.cobrancasPagas) + " cobrança(s)" : "—") + "" + (c.demonstracao ? '—' : proximaCobranca(f)) + "" + usoCurto(c.uso, c.usoDeDentro) + "" + seloSubconta(c.subconta) + "
' + f.cobrancas.map(function (cb) { @@ -3799,7 +4230,7 @@ data: return '
' + 'Plano ' + esc(nomePlano(c.plano)) + (c.ciclo === "ANUAL" ? " · anual" : " · mensal") + "" - + '

' + txt(c.cliente) + "

" + + '

' + txt(c.cliente) + (c.demonstracao ? ' demonstração' : "") + "

" + '

' + (c.escolinha && c.escolinha !== c.cliente ? esc(c.escolinha) + " · " : "") + selo(c.estado, c.tenant) + (c.estadoDesde ? " desde " + dataHora(c.estadoDesde) : "") + "

" + '
' @@ -3807,7 +4238,9 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' + + '' : "") + + '

' + (alertas ? '
    ' + alertas + "
" : "") + '
' @@ -3821,14 +4254,17 @@ data: + '

Assinatura

' + item("Plano", esc(nomePlano(c.plano)) + " " + (c.ciclo === "ANUAL" ? "anual" : "mensal")) + item("Valor do ciclo", reais(c.valorDoCiclo)) + item("Vale por mês", reais(c.valorMensal) + diferencaDeTabela(c)) - + item("De onde vem o valor", c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") - + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas)) + + item("De onde vem o valor", c.demonstracao ? "demonstração — sem cobrança" + : c.valorDaFonte === "asaas" ? "a assinatura no Asaas" : "a tabela de preços (Asaas não respondeu)") + + item("Situação no Asaas", assinaturaNoAsaas(c.assinaturaNoAsaas, c)) + item("Contratado em", dataHora(c.contratadoEm)) + item("Cliente há", c.diasDeCliente === null || c.diasDeCliente === undefined ? "—" : esc(c.diasDeCliente) + " dia(s)") + item("Assinatura no Asaas", txt(c.assinatura)) + item("Cliente no Asaas", txt(c.clienteNoProvedor)) + "
" + '

Dinheiro

' - + (f.disponivel + + (c.demonstracao + ? item("Cobrança", "nenhuma — tenant de demonstração") + : f.disponivel ? item("Total pago", reais(f.totalPago)) + item("Líquido (após taxas)", reais(f.totalLiquido)) + item("Taxas pagas", reais(f.taxas)) + item("Último pagamento", f.ultimoPagamento ? data(f.ultimoPagamento.data) + " · " + reais(f.ultimoPagamento.valor) + " · " + txt(f.ultimoPagamento.forma) : "—") @@ -4325,6 +4761,55 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca dos apps (dono, treinador, pais) ───────────────────────────── + * Reenviar = mandar a MESMA chave da escola ao dono da compra (captacao 4.32+; + * so a chave antiga, guardada so com hash, e trocada uma vez). Nada muda para + * ninguem: sem confirmacao. + * Trocar = revogar a chave e gerar outra. Desde os apps 1.0.4 (captacao 4.33) + * o app pergunta a situacao da chave ao abrir: trocar DERRUBA os apps de todos + * da escola (dono, treinadores, pais) em ate 15 min de uso, e cada um precisa + * da chave nova. Por isso pede o nome do tenant digitado. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function acaoDaLicenca(tenant, acao, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (acao === "trocar") { + var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n" + + "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta " + + "em até 15 minutos de uso; cada um vai precisar da chave nova, que vai por e-mail ao dono da compra.\n" + + "Use só se a chave vazou.\n\nPara confirmar, digite o nome do tenant (" + tenant + "):"); + if (digitado === null) return Promise.resolve(); + if (digitado.trim() !== tenant) { + aviso.textContent = "Nada foi trocado: o nome digitado não confere."; + return Promise.resolve(); + } + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + var oQue = acao === "trocar" ? "Chave NOVA" : "Chave da escola"; + aviso.textContent = oQue + (d.emailEnviado ? " enviada para " : " pronta; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + acaoDaLicenca(b.getAttribute("data-licenca"), b.getAttribute("data-acao") || "reenviar", $("licencaAviso")) + .then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); });
VencimentoPago emFormaValorLíquidoSituação