chore(tenants): conteudo de publico/ atualizado

This commit is contained in:
deploy
2026-09-29 16:51:27 +00:00
parent ad92deb861
commit 3b0c2fffaf
10 changed files with 10582 additions and 202 deletions
+559 -25
View File
@@ -196,6 +196,180 @@ data:
};
})(typeof window !== 'undefined' ? window : this);
contratar.css: |
/* A pagina /contratar. Arquivo proprio, e nao <style> embutido: a CSP do
servidor nao libera estilo inline. */
body { display: flex; min-height: 100vh; align-items: center; justify-content: center;
margin: 0; padding: 16px; box-sizing: border-box;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 32rem; width: 100%; }
h1 { font-size: 1.5rem; margin: 0 0 .75rem; }
p { line-height: 1.55; margin: 0 0 .75rem; }
.resumo { display: grid; grid-template-columns: max-content 1fr; gap: .4rem 1rem; margin: 1rem 0 1.25rem; }
.resumo dt { opacity: .7; }
.resumo dd { margin: 0; font-weight: 600; }
.aceite { display: flex; gap: .6rem; align-items: flex-start; margin: 0 0 1rem; line-height: 1.5; }
.aceite input { margin-top: .3rem; width: 1.1rem; height: 1.1rem; flex: none; }
.erro { color: #d93025; font-weight: 600; }
#seguir { padding: .8rem 1.4rem; border: 0; border-radius: 8px; background: #28c76f; color: #06210f;
font-weight: 600; font-size: 1rem; cursor: pointer; }
#seguir:disabled { opacity: .6; cursor: default; }
.nota { margin-top: 1.25rem; font-size: .85rem; opacity: .65; }
contratar.html: |
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="referrer" content="no-referrer">
<title>Confirmar contratação — Athletic Map</title>
<link rel="stylesheet" href="/publico/_estilo.css">
<link rel="stylesheet" href="/publico/contratar.css?v=1">
</head>
<body data-situacao="carregando">
<!-- O link de pagamento aberto por quem fechou a venda (endpoint administrativo)
chega AQUI, e nao direto no Asaas: quem paga ve o plano, o preco e aceita os
Termos de Uso antes do checkout. Ver AbrirAssinaturaDoLead (V024). -->
<main>
<h1 id="titulo">Confirmar contratação</h1>
<p id="mensagem" aria-live="polite">Carregando…</p>
<form id="formulario" hidden novalidate>
<dl class="resumo">
<dt>Escolinha</dt><dd id="escolinha">—</dd>
<dt>Plano</dt><dd id="plano">—</dd>
<dt>Valor</dt><dd id="valor">—</dd>
</dl>
<div class="aceite">
<input id="termos" type="checkbox">
<label for="termos">Li e aceito os <a href="/termos" target="_blank" rel="noopener">Termos de Uso</a>
(versão <span id="versao">—</span>) para contratar a Athletic Map.</label>
</div>
<p id="erro" class="erro" role="alert" hidden></p>
<button id="seguir" type="submit">Aceitar e ir para o pagamento</button>
<p class="nota">O pagamento é feito no Asaas, nosso parceiro de cobrança.</p>
</form>
</main>
<script src="/publico/contratar.js?v=1"></script>
</body>
</html>
contratar.js: |
/* ===========================================================================
* A pagina /contratar — o aceite dos Termos de Uso ANTES do checkout.
*
* Quem fecha a venda abre o link pelo endpoint administrativo, e o que o
* comprador recebe e ESTE endereco (`/contratar?c=TOKEN`), e nao o do Asaas. A
* pagina mostra o plano e o preco, pede a caixa dos Termos e so entao recebe do
* servidor o endereco do checkout — que nao existe em nenhum outro lugar fora do
* banco. Ver AbrirAssinaturaDoLead e V024.
*
* Script em arquivo, e nao inline: a CSP do servidor diz `script-src 'self'`.
* =========================================================================== */
(function () {
"use strict";
var API = "/api/public/contratacoes/confirmacao/";
var token = new URLSearchParams(location.search).get("c");
var titulo = document.getElementById("titulo");
var mensagem = document.getElementById("mensagem");
var formulario = document.getElementById("formulario");
var caixa = document.getElementById("termos");
var erro = document.getElementById("erro");
var botao = document.getElementById("seguir");
var versao = null;
/* A unica saida da pagina. Costura para o teste ver PARA ONDE ela manda — o
jsdom nao deixa trocar `location.assign`. */
function navegar(url) {
if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; }
location.assign(url);
}
function reais(v) {
var n = Number(v);
return isFinite(n) ? n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }) : "—";
}
function nomeDoPlano(p) {
var n = String(p || "");
return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—";
}
function falhar(texto) {
document.body.setAttribute("data-situacao", "falhou");
titulo.textContent = "Não foi possível abrir esta contratação";
mensagem.textContent = texto;
formulario.hidden = true;
}
function mostrarErro(texto) {
erro.textContent = texto;
erro.hidden = false;
}
if (!token) {
falhar("O endereço está incompleto. Peça um link novo a quem o enviou.");
return;
}
fetch(API + encodeURIComponent(token), { headers: { "Accept": "application/json" } })
.then(function (r) {
if (r.status === 404) throw new Error("Este link de contratação não existe. Peça um novo a quem o enviou.");
if (!r.ok) throw new Error("Não conseguimos carregar agora. Tente de novo em instantes.");
return r.json();
})
.then(function (d) {
versao = d.versao;
// textContent em tudo: o nome da escolinha foi digitado por alguem.
document.getElementById("escolinha").textContent = d.escolinha || "—";
document.getElementById("plano").textContent = nomeDoPlano(d.plano)
+ (d.ciclo === "ANUAL" ? " (anual)" : " (mensal)");
document.getElementById("valor").textContent = reais(d.valor) + (d.ciclo === "ANUAL" ? "/ano" : "/mês");
document.getElementById("versao").textContent = d.versao || "—";
mensagem.textContent = "Confira o que você está contratando e aceite os Termos de Uso para seguir ao pagamento.";
document.body.setAttribute("data-situacao", "pronta");
formulario.hidden = false;
})
.catch(function (e) { falhar(e.message); });
formulario.addEventListener("submit", function (ev) {
ev.preventDefault();
erro.hidden = true;
if (!caixa.checked) {
mostrarErro("Para contratar, leia e aceite os Termos de Uso.");
return;
}
botao.disabled = true;
fetch(API + encodeURIComponent(token), {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ termos_aceitos: true, termos_versao: versao })
}).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (d) {
if (r.ok && typeof d.link === "string" && /^https:\/\//.test(d.link)) {
navegar(d.link);
return;
}
if (d.codigo === "termos_desatualizados") {
// O texto mudou enquanto a pagina estava aberta: mostra a versao nova
// e pede a caixa de novo — o aceite antigo nao vale para ela.
versao = d.versao;
document.getElementById("versao").textContent = d.versao || "—";
caixa.checked = false;
}
throw new Error(d.erro || d.mensagem || "Não conseguimos seguir agora. Tente de novo em instantes.");
});
}).catch(function (e) {
mostrarErro(e.message);
botao.disabled = false;
});
});
})();
fontes--fontes.css: |
/* ============================================================
* As fontes da landing page, servidas POR NOS.
@@ -471,6 +645,9 @@ data:
.consent{display:grid; grid-template-columns:18px 1fr; gap:10px; align-items:start;}
.consent input{width:18px; height:18px; margin-top:3px; accent-color:var(--laranja);}
.consent label{font-family:"Barlow",sans-serif; font-size:.81rem; font-weight:400; color:var(--muted); line-height:1.45; margin:0;}
/* O aceite dos Termos de Uso so existe na contratacao (plano escolhido). */
.consent.termos{display:none;}
.consent.termos.visivel{display:grid;}
.btn{display:inline-flex; align-items:center; justify-content:center; gap:9px; width:100%; border:none; border-radius:var(--r-pill); background:linear-gradient(100deg,var(--laranja) 0%,#FF9E42 100%); color:#fff; font-size:1.05rem; font-weight:600; padding:15px 22px; cursor:pointer; text-decoration:none; box-shadow:0 6px 16px -8px rgba(245,130,31,.9);}
.btn:hover{filter:brightness(1.05)}
.form-foot{font-size:.79rem; color:var(--muted); text-align:center; margin:13px 0 0;}
@@ -619,7 +796,7 @@ data:
<meta property="og:type" content="website">
<title>Athletic Map para Escolinhas</title>
<link rel="stylesheet" href="/publico/fontes/fontes.css">
<link rel="stylesheet" href="/publico/lp-escolinhas.css">
<link rel="stylesheet" href="/publico/lp-escolinhas.css?v=2.86">
</head>
<body>
@@ -695,6 +872,15 @@ data:
<input id="consent" name="consent" type="checkbox" required>
<label for="consent">Autorizo a Athletic Map a entrar em contato e a tratar meus dados para esta finalidade, conforme a <a href="/privacidade" target="_blank" rel="noopener">Política de Privacidade</a>.</label>
</div>
<!-- So aparece com um plano escolhido (lp-escolinhas.js, `escolher`). Sem
este aceite a contratacao e recusada no servidor (400). O
`data-versao` TEM de ser a versao do termos.html e a
`atm.captacao.termos-de-uso-versao` do servico-captacao: mudou o
texto dos Termos, suba as tres juntas. -->
<div class="consent termos" id="aceiteTermos">
<input id="termos" type="checkbox" data-versao="1.0">
<label for="termos">Li e aceito os <a href="/termos" target="_blank" rel="noopener">Termos de Uso</a> (versão 1.0) para contratar a Athletic Map.</label>
</div>
<button class="btn" type="submit">Quero ver funcionando <span aria-hidden="true">→</span></button>
</div>
</form>
@@ -833,7 +1019,7 @@ data:
<!-- O portao de consentimento. Tem de vir ANTES do script da pagina: e ele
que decide se Meta e Google carregam. Ver publico/consentimento.js. -->
<script src="/publico/consentimento.js"></script>
<script src="/publico/lp-escolinhas.js?v=2.85"></script>
<script src="/publico/lp-escolinhas.js?v=2.86"></script>
</body>
</html>
@@ -1006,6 +1192,19 @@ data:
});
}
/* O aceite dos Termos de Uso. So aparece — e so e exigido — com um plano
escolhido: e contrato, e quem so pede contato nao contrata nada. O servidor
recusa a contratacao sem ele (400), na versao que esta no `data-versao`. */
var termos = document.getElementById("termos");
var caixaTermos = document.getElementById("aceiteTermos");
function mostrarTermos(sim) {
if (caixaTermos) caixaTermos.classList.toggle("visivel", sim);
if (termos) {
termos.required = sim;
if (!sim) termos.checked = false;
}
}
function escolher(plano) {
escolha = { plano: plano, ciclo: cicloAtual };
var aviso = document.getElementById("planoEscolhido");
@@ -1017,6 +1216,8 @@ data:
}
var b = form.querySelector("button[type=submit]");
if (b) b.innerHTML = "Ir para o pagamento &rarr;";
// Contratar exige o aceite dos Termos de Uso; pedir contato, nao.
mostrarTermos(true);
track("InitiateCheckout", { content_name: plano, content_category: cicloAtual });
document.getElementById("form").scrollIntoView({ behavior: "smooth", block: "start" });
}
@@ -1027,6 +1228,7 @@ data:
if (aviso) aviso.classList.remove("visivel");
var b = form.querySelector("button[type=submit]");
if (b) b.innerHTML = "Quero ver funcionando &rarr;";
mostrarTermos(false);
}
Array.prototype.forEach.call(document.querySelectorAll(".ciclo"), function (b) {
@@ -1114,6 +1316,17 @@ data:
if (!form.reportValidity()) return;
var data = Object.fromEntries(new FormData(form).entries());
data.consent = !!document.getElementById("consent").checked;
// A decisao do banner de medicao viaja com o lead. E ela que o servidor
// consulta antes de mandar o `Purchase` ao Meta e ao Google: quem recusou
// aqui nao e medido la. Sem decisao vai null — e null, la, e nao.
var decisao = AtmConsentimento.estado();
data.consentimento_medicao =
(decisao === AtmConsentimento.ACEITO || decisao === AtmConsentimento.RECUSADO) ? decisao : null;
if (escolha && !(termos && termos.checked)) {
alert("Para contratar, leia e aceite os Termos de Uso.");
if (termos && termos.focus) termos.focus();
return;
}
Object.assign(data, tracking);
data.pagina = location.pathname;
data.enviado_em = new Date().toISOString();
@@ -1181,7 +1394,9 @@ data:
if (escolha) {
fetch(CONFIG.CONTRATACAO_ENDPOINT, {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({lead: data, plano: escolha.plano, ciclo: escolha.ciclo})
body: JSON.stringify({lead: data, plano: escolha.plano, ciclo: escolha.ciclo,
termos_aceitos: true,
termos_versao: termos.getAttribute("data-versao")})
}).then(function(r){
return r.json().catch(function(){ return null; })
.then(function(corpo){ r.ok ? contratado(corpo) : recusado(corpo); });
@@ -1460,6 +1675,29 @@ data:
.lista-planos .selo { margin: 0; }
.plano-troca .link { background: none; border: 0; color: var(--orange-600); font-weight: 600; cursor: pointer; padding: 0; font: inherit; }
/* ── N16: a visao geral das escolinhas ──────────────────────────────────────
Uma tabela: metricas nas linhas, escolinhas nas colunas, o somado por
ultimo. Rola de lado DENTRO do cartao no celular — a pagina nao. */
.visao .hl { color: var(--orange); }
.visao-tabela { margin-top: 14px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.visao-tabela table { border-collapse: collapse; width: 100%; font-size: 14px; min-width: 420px; }
.visao-tabela th, .visao-tabela td { padding: 9px 12px; text-align: right; border-top: 1px solid var(--line); white-space: nowrap; }
.visao-tabela thead th { border-top: 0; vertical-align: bottom; font-weight: 700; color: var(--navy); }
.visao-tabela thead th small { display: block; font-weight: 400; color: var(--muted); font-size: 12px; }
.visao-tabela th[scope="row"] { text-align: left; font-weight: 500; color: var(--muted); }
.visao-tabela .grupo th { text-align: left; padding-top: 16px; font-family: 'IBM Plex Mono', monospace;
font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--orange-600); border-top: 0; }
.visao-tabela .somado { background: #FFF6EC; font-weight: 700; }
.visao-tabela td.vazio { color: var(--muted); }
.visao-tabela td small { display: block; color: var(--muted); font-size: 12px; font-weight: 400; }
.visao .nota { font-size: 13px; margin-top: 10px; }
/* Os Termos de Uso (V024): o aviso de versao nova e a caixa dentro de uma acao. */
.aviso-termos { border-left: 4px solid var(--orange-600); }
.termos-uso-caixa { display: block; margin: 8px 0; line-height: 1.5; }
.termos-uso-caixa input { margin-right: 6px; }
.termos-uso-aceite { margin: 10px 0; }
minha-conta.html: |
<!DOCTYPE html>
<html lang="pt-BR">
@@ -1484,7 +1722,7 @@ data:
numero a cada publicacao que mexa nesses arquivos.
-->
<link rel="stylesheet" href="/publico/fontes/fontes.css">
<link rel="stylesheet" href="/publico/minha-conta.css?v=2.85">
<link rel="stylesheet" href="/publico/minha-conta.css?v=2.88">
</head>
<body>
@@ -1527,6 +1765,15 @@ data:
</section>
<section id="conteudo" class="oculto">
<!-- N16: a visao geral das escolinhas DESTE comprador, lado a lado e somada.
So numeros agregados, que cada escolinha envia uma vez por dia.
Fica oculta enquanto nenhuma escolinha tiver mandado o resumo. -->
<section id="visao" class="cartao visao oculto" aria-labelledby="visaoTitulo">
<h2 id="visaoTitulo">Vis&atilde;o <span class="hl">geral</span></h2>
<p id="visaoSub" class="ajuda"></p>
<div id="visaoTabela" class="visao-tabela"></div>
</section>
<div id="produtos" class="produtos"></div>
<div id="nenhum" class="cartao oculto">
@@ -1554,7 +1801,7 @@ data:
<footer class="direitos">&copy; 2026 Athletic Map. Todos os direitos reservados.</footer>
<script src="/publico/minha-conta.js?v=2.86"></script>
<script src="/publico/minha-conta.js?v=2.88"></script>
</body>
</html>
@@ -1596,7 +1843,9 @@ data:
TERMO: "/api/cliente/termo-de-dados",
FATURAS: "/api/cliente/faturas",
PLANO: "/api/cliente/plano",
MODULOS: "/api/cliente/modulos"
MODULOS: "/api/cliente/modulos",
TERMOS_DE_USO: "/api/cliente/termos-de-uso",
VISAO: "/api/cliente/visao-geral"
};
var CHAVE_VERIFIER = "atm.conta.verifier";
@@ -1769,7 +2018,8 @@ data:
nenhum.classList.toggle("oculto", (lista || []).length > 0);
mostrar(conteudo);
(lista || []).forEach(function (p) { if (p.tenant && p.situacao === "ATIVA") acrescentarBotaoDePlano(p.tenant); });
return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas()]);
return Promise.all([carregarCobranca(), carregarTermo(), carregarFaturas(), carregarVisao(),
carregarTermosDeUso()]);
});
}
@@ -1948,12 +2198,7 @@ data:
if (corpo) h["Content-Type"] = "application/json";
return fetch(CONFIG.PLANO + "/" + encodeURIComponent(tenant), {
method: metodo, headers: h, body: corpo ? JSON.stringify(corpo) : undefined
}).then(function (r) {
if (r.ok) return r.json();
return r.json().catch(function () { return {}; }).then(function (d) {
throw new Error(d.erro || "Não consegui agora. Tente de novo em instantes.");
});
});
}).then(respostaDaAcao);
}
function blocoDePlano(d) {
@@ -2000,7 +2245,13 @@ data:
var plano = botao.getAttribute("data-plano");
if (typeof window.confirm === "function" && !window.confirm("Mudar para " + (NOME_DO_PLANO[plano] || plano)
+ "? " + botao.getAttribute("data-quando") + ".")) { botao.disabled = false; return Promise.resolve(); }
return pedirPlano("POST", tenant, { plano: plano }).then(function (d) { mostrarPlano(tenant, d); }).catch(erro);
var trocar = function (aceite) {
var corpo = { plano: plano };
if (aceite) { corpo.termos_aceitos = aceite.termos_aceitos; corpo.termos_versao = aceite.termos_versao; }
return pedirPlano("POST", tenant, corpo).then(function (d) { mostrarPlano(tenant, d); })
.catch(function (e) { return e.termos ? pedirAceite(caixa, e.termos, "mudar de plano", trocar, botao) : erro(e); });
};
return trocar(null);
}
if (acao === "cancelar") {
return pedirPlano("DELETE", tenant).then(function (d) { mostrarPlano(tenant, d); }).catch(erro);
@@ -2013,21 +2264,124 @@ data:
if (b) agirNoPlano(b);
});
/* ── OS TERMOS DE USO (V024) ─────────────────────────────────────────────────
*
* Trocar de plano e comprar modulo MUDAM o contrato: se ele ainda nao aceitou a
* versao vigente dos Termos de Uso, o servidor responde 428 com a versao, e a
* caixa aparece ali mesmo — marcada, a acao e repetida levando o aceite. Se ja
* aceitou, nada e perguntado.
*
* Versao nova dos Termos: um aviso no topo pede o aceite, SEM bloquear nada —
* faturas, dados e o proprio sistema continuam. So as acoes que mudam o
* contrato pedem. Cancelar (troca agendada ou modulo) nunca pede. */
function respostaDaAcao(r) {
if (r.ok) return r.json();
return r.json().catch(function () { return {}; }).then(function (d) {
var e = new Error(d.erro || "Não consegui agora. Tente de novo em instantes.");
if (r.status === 428 && d.versao) e.termos = d;
throw e;
});
}
function caixaDeAceite(versao, rotulo) {
return '<label class="termos-uso-caixa"><input type="checkbox"> Li e aceito os '
+ '<a href="/termos" target="_blank" rel="noopener">Termos de Uso</a> (vers&atilde;o '
+ escapar(versao) + ")</label> "
+ '<button type="button" class="btn">' + rotulo + "</button>"
+ '<p class="erro-form oculto"></p>';
}
/* A caixa dentro do cartao da acao. `repetir` refaz o pedido com o aceite. */
function pedirAceite(caixa, t, oQue, repetir, botao) {
caixa.querySelectorAll(".termos-uso-aceite").forEach(function (el) { el.remove(); });
var div = document.createElement("div");
div.className = "termos-uso-aceite";
div.innerHTML = '<p class="ajuda">Para ' + escapar(oQue) + ", aceite os Termos de Uso vigentes.</p>"
+ caixaDeAceite(t.versao, "Aceitar e continuar");
caixa.insertBefore(div, caixa.querySelector(".erro-form"));
div.querySelector("button").addEventListener("click", function () {
var aviso = div.querySelector(".erro-form");
if (!div.querySelector("input").checked) {
aviso.textContent = "Marque a caixa para aceitar os Termos de Uso.";
aviso.classList.remove("oculto");
return;
}
div.querySelector("button").disabled = true;
repetir({ termos_aceitos: true, termos_versao: t.versao }).then(function () {
if (div.parentNode) div.parentNode.removeChild(div);
carregarTermosDeUso();
});
});
if (botao) botao.disabled = false;
return Promise.resolve();
}
function carregarTermosDeUso() {
return fetch(CONFIG.TERMOS_DE_USO, { headers: { Authorization: "Bearer " + token } })
.then(function (r) { return r.ok ? r.json() : null; })
.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; });
if (!faltam.length) return;
var aviso = document.createElement("section");
aviso.id = "avisoTermos";
aviso.className = "cartao aviso-termos";
aviso.innerHTML = "<h2>Termos de Uso</h2>"
+ '<p class="ajuda">A vers&atilde;o ' + escapar(d.versao) + " dos Termos de Uso ainda n&atilde;o foi aceita para "
+ faltam.map(function (e) { return "<b>" + escapar(e.escolinha || e.tenant) + "</b>"; }).join(", ")
+ ". Voc&ecirc; continua usando o sistema, vendo faturas e dados normalmente; s&oacute; trocar de plano"
+ " e comprar m&oacute;dulos pedem o aceite.</p>"
+ caixaDeAceite(d.versao, "Aceitar");
produtos.parentNode.insertBefore(aviso, produtos);
aviso.querySelector("button").addEventListener("click", function () {
aceitarTermosDeUso(aviso, d.versao);
});
})
.catch(function (e) { if (window.console) console.warn("termos de uso:", e); });
}
function aceitarTermosDeUso(aviso, versao) {
var erro = aviso.querySelector(".erro-form");
var botao = aviso.querySelector("button");
if (!aviso.querySelector("input").checked) {
erro.textContent = "Marque a caixa para aceitar os Termos de Uso.";
erro.classList.remove("oculto");
return Promise.resolve();
}
botao.disabled = true;
return fetch(CONFIG.TERMOS_DE_USO, {
method: "POST",
headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" },
body: JSON.stringify({ termos_aceitos: true, termos_versao: versao })
}).then(function (r) {
if (r.ok) return carregarTermosDeUso();
return r.json().catch(function () { return {}; }).then(function (d) {
// Versao trocou com a pagina aberta: o aviso volta com a nova.
if (d.codigo === "termos_desatualizados") return carregarTermosDeUso();
throw new Error(d.erro || "Não consegui registrar agora. Tente de novo em instantes.");
});
}).catch(function (e) {
erro.textContent = e.message;
erro.classList.remove("oculto");
botao.disabled = false;
});
}
/* ── A LOJA DE MODULOS ───────────────────────────────────────────────────────
*
* Mesmas regras da troca de plano: comprar vale na hora e o valor entra na
* proxima cobranca; cancelar vale no fim do periodo pago. O que o plano ja
* inclui aparece como incluido, sem botao. */
function pedirModulo(metodo, tenant, modulo) {
function pedirModulo(metodo, tenant, modulo, corpo) {
var h = { Authorization: "Bearer " + token };
if (corpo) h["Content-Type"] = "application/json";
return fetch(CONFIG.MODULOS + "/" + encodeURIComponent(tenant) + (modulo ? "/" + encodeURIComponent(modulo) : ""), {
method: metodo, headers: { Authorization: "Bearer " + token }
}).then(function (r) {
if (r.ok) return r.json();
return r.json().catch(function () { return {}; }).then(function (d) {
throw new Error(d.erro || "Não consegui agora. Tente de novo em instantes.");
});
});
method: metodo, headers: h, body: corpo ? JSON.stringify(corpo) : undefined
}).then(respostaDaAcao);
}
function blocoDaLoja(d) {
@@ -2070,7 +2424,11 @@ data:
if (acao === "comprar") {
if (typeof window.confirm === "function" && !window.confirm("Comprar " + botao.getAttribute("data-nome")
+ "? Libera na hora; o valor entra na próxima cobrança.")) { botao.disabled = false; return Promise.resolve(); }
return pedirModulo("POST", tenant, modulo).then(mostrar).catch(erro);
var comprar = function (aceite) {
return pedirModulo("POST", tenant, modulo, aceite).then(mostrar)
.catch(function (e) { return e.termos ? pedirAceite(caixa, e.termos, "comprar o módulo", comprar, botao) : erro(e); });
};
return comprar(null);
}
if (acao === "cancelar") {
if (typeof window.confirm === "function" && !window.confirm("Cancelar este módulo? Ele continua até o fim do período pago."))
@@ -2140,6 +2498,109 @@ data:
.catch(function (e) { if (window.console) console.warn("faturas:", e); });
}
/* ── A VISAO GERAL (N16) ─────────────────────────────────────────────────────
*
* As escolinhas DESTE comprador lado a lado, e o somado quando ha mais de uma.
* Os numeros sao AGREGADOS que cada escolinha envia uma vez por dia — nenhum
* dado de aluno sai de la. Sem nenhuma escolinha com resumo, o bloco nem
* aparece. Como os outros complementos, falhar aqui nao derruba a tela. */
var MESES = ["janeiro", "fevereiro", "mar&ccedil;o", "abril", "maio", "junho", "julho",
"agosto", "setembro", "outubro", "novembro", "dezembro"];
function nomeDoMes(competencia) {
var m = /^(\d{4})-(\d{2})$/.exec(String(competencia || ""));
return m ? MESES[Number(m[2]) - 1] + " de " + m[1] : "";
}
function inteiro(v) {
var n = Number(v);
return v === null || v === undefined || isNaN(n) ? null : String(Math.round(n));
}
function pct(v) {
var n = Number(v);
return v === null || v === undefined || isNaN(n) ? null : n.toFixed(1).replace(".", ",") + "%";
}
/* Cada linha: rotulo, e como tirar o texto (e a observacao, se houver) de uma
coluna. `null` vira "—": o numero nao existe para aquela escolinha (sem o
modulo Financeiro, por exemplo), o que e diferente de zero. */
var LINHAS_DA_VISAO = [
["Finan&ccedil;as"],
["Recebido", function (c) { return c.financeiro ? [reais(c.financeiro.recebido)] : null; }],
["A receber", function (c) { return c.financeiro ? [reais(c.financeiro.aReceber)] : null; }],
["Inadimpl&ecirc;ncia", function (c) {
if (!c.financeiro) return null;
var p = pct(c.financeiro.percentualInadimplencia);
return [reais(c.financeiro.vencido), p ? p + " do previsto" : null];
}],
["Alunos"],
["Alunos ativos", function (c) { return [inteiro(c.alunosAtivos)]; }],
["Frequ&ecirc;ncia nos treinos", function (c) {
var p = pct(c.frequencia);
return p ? [p, inteiro(c.presencas) + " presen&ccedil;as"] : null;
}],
["Ades&otilde;es (novos atletas)", function (c) { return [inteiro(c.adesoes)]; }],
["Matr&iacute;culas novas", function (c) { var n = inteiro(c.matriculasNovas); return n === null ? null : [n]; }],
["Evas&atilde;o (matr&iacute;culas canceladas)", function (c) { var n = inteiro(c.evasoes); return n === null ? null : [n]; }],
["Atividade do m&ecirc;s"],
["Treinos", function (c) { return [inteiro(c.treinos)]; }],
["Jogos", function (c) { return [inteiro(c.jogos)]; }]
];
function celula(par, somado) {
var classe = somado ? "somado" : "";
if (!par || par[0] === null || par[0] === undefined) {
return '<td class="vazio ' + classe + '">&mdash;</td>';
}
return '<td class="' + classe + '">' + escapar(par[0])
+ (par[1] ? "<small>" + par[1] + "</small>" : "") + "</td>";
}
function blocoDaVisao(d) {
var escolas = (d.escolas || []).filter(function (e) { return e.temDados; });
if (!escolas.length) return null;
var colunas = escolas.slice();
if (d.total) colunas.push(Object.assign({ somado: true }, d.total));
var cabeca = "<thead><tr><th></th>" + colunas.map(function (c) {
if (c.somado) return '<th class="somado" scope="col">Somado<small>' + escolas.length + " escolinhas</small></th>";
return '<th scope="col">' + escapar(c.escolinha || c.tenant || "Escolinha")
+ (c.atualizadoEm ? "<small>atualizado em " + escapar(dataCurta(c.atualizadoEm)) + "</small>" : "")
+ "</th>";
}).join("") + "</tr></thead>";
var corpo = "<tbody>" + LINHAS_DA_VISAO.map(function (l) {
if (!l[1]) return '<tr class="grupo"><th colspan="' + (colunas.length + 1) + '">' + l[0] + "</th></tr>";
return '<tr><th scope="row">' + l[0] + "</th>" + colunas.map(function (c) {
return celula(l[1](c), c.somado);
}).join("") + "</tr>";
}).join("") + "</tbody>";
return "<table>" + cabeca + corpo + "</table>";
}
function carregarVisao() {
var caixa = document.getElementById("visao");
if (!caixa) return Promise.resolve();
return fetch(CONFIG.VISAO, { headers: { Authorization: "Bearer " + token } })
.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 semDados = (d.escolas || []).filter(function (e) { return !e.temDados; }).length;
document.getElementById("visaoSub").innerHTML = "N&uacute;meros de " + nomeDoMes(d.competencia)
+ ". S&oacute; totais &mdash; nenhum dado de aluno sai da escolinha. Atualizados uma vez por dia.";
document.getElementById("visaoTabela").innerHTML = tabela
+ (semDados ? '<p class="ajuda nota">' + semDados + (semDados === 1 ? " escolinha ainda n&atilde;o enviou"
: " escolinhas ainda n&atilde;o enviaram") + " o resumo do m&ecirc;s.</p>" : "")
+ '<p class="ajuda nota">&mdash; = n&atilde;o medido (por exemplo, sem o m&oacute;dulo Financeiro no plano).</p>';
caixa.classList.remove("oculto");
})
.catch(function (e) { if (window.console) console.warn("visao:", e); });
}
/* ── O TERMO DE DADOS ────────────────────────────────────────────────────────
*
* A autorizacao para a Athletic Map ver o USO do sistema (ultimo acesso,
@@ -2631,7 +3092,7 @@ data:
`Cache-Control: immutable` por um ano. Suba o numero a cada publicacao.
-->
<link rel="stylesheet" href="/publico/fontes/fontes.css">
<link rel="stylesheet" href="/publico/painel.css?v=2.85">
<link rel="stylesheet" href="/publico/painel.css?v=2.86">
</head>
<body>
@@ -2691,6 +3152,7 @@ data:
<button type="button" class="aba" data-aba="receita" role="tab">Receita e carteira</button>
<button type="button" class="aba" data-aba="atividade" role="tab">Atividade</button>
<button type="button" class="aba" data-aba="termo" role="tab">Termo de dados</button>
<button type="button" class="aba" data-aba="termosdeuso" role="tab">Termos de Uso</button>
<button type="button" class="aba" data-aba="catalogo" role="tab">Produtos e pre&ccedil;os</button>
</nav>
@@ -2821,6 +3283,30 @@ data:
</div>
</section>
<!-- TERMOS DE USO: quem aceitou qual versao (V024) -->
<section id="aba-termosdeuso" class="painel-aba oculto">
<div class="cartao">
<h2>Termos de Uso</h2>
<p class="ajuda">
Vers&atilde;o vigente: <b id="termosUsoVigente">&mdash;</b>. Uma linha por contrata&ccedil;&atilde;o,
com o aceite que vale (o da vers&atilde;o vigente, se houver). Quem est&aacute; numa vers&atilde;o
antiga v&ecirc; o pedido de aceite no /minha-conta, e s&oacute; troca de plano ou compra
m&oacute;dulo depois de aceitar.
</p>
<div class="tabela-rolagem">
<table class="tabela"><thead><tr><th>Escolinha</th><th>E-mail</th><th>Vers&atilde;o aceita</th><th>Quando</th><th>Por onde</th></tr></thead>
<tbody id="termosUsoContratos"><tr><td colspan="5" class="ajuda">Abra esta aba para carregar.</td></tr></tbody>
</table>
</div>
<h3>&Uacute;ltimos aceites</h3>
<div class="tabela-rolagem">
<table class="tabela"><thead><tr><th>Quando</th><th>Vers&atilde;o</th><th>Por onde</th><th>A&ccedil;&atilde;o</th><th>Quem</th></tr></thead>
<tbody id="termosUsoHistorico"></tbody>
</table>
</div>
</div>
</section>
<!-- TERMO DE DADOS: o que autoriza ler o uso de dentro de cada sistema -->
<section id="aba-termo" class="painel-aba oculto">
<div class="cartao">
@@ -2848,7 +3334,7 @@ data:
</main>
<script src="/publico/painel.js?v=2.85"></script>
<script src="/publico/painel.js?v=2.86"></script>
</body>
</html>
@@ -2885,6 +3371,7 @@ data:
var API_ALERTAS = "/api/plataforma/painel/alertas-infra";
var API_TERMO = "/api/plataforma/painel/termo-de-dados";
var API_CATALOGO = "/api/plataforma/painel/catalogo";
var API_TERMOS_DE_USO = "/api/plataforma/painel/termos-de-uso";
var CHAVE = "atm.painel.token";
var INTERVALO_SINO_MS = 60000;
@@ -3679,6 +4166,52 @@ data:
});
});
/* ── termos de uso: quem aceitou qual versao (V024) ─────────────────────── */
var ORIGEM_DO_ACEITE = { LANDING: "landing page", LINK_ADMIN: "link enviado", MINHA_CONTA: "minha conta" };
var ACAO_DO_ACEITE = {
CONTRATACAO: "contratação", TROCA_DE_PLANO: "troca de plano",
COMPRA_DE_MODULO: "compra de módulo", ATUALIZACAO_DOS_TERMOS: "versão nova"
};
function linhaDeContrato(c, vigente) {
var versao = c.versao
? '<span class="selo ' + (c.emDia ? "ok" : "alerta") + '">' + esc(c.versao) + "</span>"
: '<span class="selo alerta">sem aceite</span>';
return "<tr><td>" + txt(c.escolinha) + (c.tenant ? ' <span class="ajuda">' + esc(c.tenant) + "</span>" : "")
+ "</td><td>" + txt(c.email) + "</td><td>" + versao
+ (c.versao && !c.emDia ? ' <span class="ajuda">(vigente: ' + esc(vigente) + ")</span>" : "")
+ "</td><td>" + (c.aceitoEm ? esc(dataHora(c.aceitoEm)) : "—")
+ "</td><td>" + txt(ORIGEM_DO_ACEITE[c.origem] || c.origem) + "</td></tr>";
}
function linhaDoHistorico(a) {
return "<tr><td>" + esc(dataHora(a.aceitoEm)) + "</td><td>" + txt(a.versao)
+ "</td><td>" + txt(ORIGEM_DO_ACEITE[a.origem] || a.origem)
+ "</td><td>" + txt(ACAO_DO_ACEITE[a.acao] || a.acao)
+ "</td><td>" + txt(a.email) + (a.tenant ? ' <span class="ajuda">' + esc(a.tenant) + "</span>" : "")
+ "</td></tr>";
}
function carregarTermosDeUso() {
if (!tokenAtual) return Promise.resolve();
return fetch(API_TERMOS_DE_USO, { headers: { "x-atm-admin-token": tokenAtual } })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
d = d || {};
$("termosUsoVigente").textContent = d.versaoVigente || "—";
$("termosUsoContratos").innerHTML = (d.contratos || []).map(function (c) {
return linhaDeContrato(c, d.versaoVigente);
}).join("") || '<tr><td colspan="5" class="ajuda">Nenhuma contratação ainda.</td></tr>';
$("termosUsoHistorico").innerHTML = (d.aceites || []).map(linhaDoHistorico).join("")
|| '<tr><td colspan="5" class="ajuda">Nenhum aceite registrado.</td></tr>';
})
.catch(function (e) {
$("termosUsoContratos").innerHTML = '<tr><td colspan="5" class="ajuda">Não consegui carregar: '
+ esc(e.message) + "</td></tr>";
});
}
/* ── montagem ───────────────────────────────────────────────────────────── */
function pintar() {
@@ -3778,6 +4311,7 @@ data:
p.classList.toggle("oculto", p.id !== "aba-" + b.getAttribute("data-aba"));
});
if (b.getAttribute("data-aba") === "termo") carregarTermo();
if (b.getAttribute("data-aba") === "termosdeuso") carregarTermosDeUso();
if (b.getAttribute("data-aba") === "catalogo") carregarCatalogo();
});
});