chore(tenants): conteudo de publico/ atualizado

This commit is contained in:
deploy
2026-10-05 21:56:44 +00:00
parent 3bc084c285
commit 0a2b7d66bb
2 changed files with 730 additions and 4 deletions
+365 -2
View File
@@ -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: |
<!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, nofollow">
<meta name="referrer" content="no-referrer">
<title>Convite para competição — Athletic Map</title>
<link rel="stylesheet" href="/publico/_estilo.css">
<link rel="stylesheet" href="/publico/convite.css?v=1">
</head>
<body>
<header><div class="marca">Athletic <span>Map</span></div></header>
<main>
<h1 id="titulo">Convite para competição</h1>
<p id="situacao" role="status" aria-live="polite">Abrindo o convite…</p>
<form id="formulario" hidden novalidate>
<p class="explica">
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.
</p>
<fieldset>
<legend>Seu time</legend>
<label>Nome do time *<input id="nome" maxlength="120" autocomplete="off" required></label>
<label>Escola ou clube<input id="escola" maxlength="160" autocomplete="organization"></label>
<label>Cidade<input id="cidade" maxlength="120" autocomplete="address-level2"></label>
</fieldset>
<fieldset>
<legend>Responsável pelo time</legend>
<label>Nome<input id="contatoNome" maxlength="120" autocomplete="name"></label>
<label>Telefone<input id="contatoTelefone" maxlength="40" autocomplete="tel" inputmode="tel"></label>
<label>E-mail<input id="contatoEmail" type="email" maxlength="160" autocomplete="email"></label>
</fieldset>
<fieldset>
<legend>Jogadores</legend>
<div id="jogadores"></div>
<button type="button" id="maisJogador" class="secundario">+ Jogador</button>
</fieldset>
<p id="erro" class="erro" role="alert" hidden></p>
<button type="submit" id="enviar">Enviar time</button>
</form>
</main>
<script src="/publico/desafio.js?v=1"></script>
<script src="/publico/convite.js?v=2"></script>
</body>
</html>
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.
@@ -3430,7 +3756,7 @@ data:
</main>
<script src="/publico/painel.js?v=2.87"></script>
<script src="/publico/painel.js?v=2.88"></script>
</body>
</html>
@@ -3912,7 +4238,8 @@ data:
+ (whatsapp(c.whatsapp) ? '<a class="btn claro" href="' + esc(whatsapp(c.whatsapp)) + '" target="_blank" rel="noopener noreferrer">WhatsApp</a>' : "")
+ (c.email ? '<a class="btn claro" href="mailto:' + esc(c.email) + '">E-mail</a>' : "")
+ (f.faturaVencida ? '<a class="btn claro" href="' + esc(linkSeguro(f.faturaVencida) || "#") + '" target="_blank" rel="noopener noreferrer">Fatura vencida</a>' : "")
+ "</div></header>"
+ (c.tenant ? '<button type="button" class="btn claro" data-licenca="' + esc(c.tenant) + '">Reenviar licença</button>' : "")
+ '</div><p id="licencaAviso" class="ajuda" aria-live="polite"></p></header>'
+ (alertas ? '<ul class="lista-alertas">' + alertas + "</ul>" : "")
+ '<div class="grade-ficha">'
@@ -4433,6 +4760,42 @@ 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. */
var API_LICENCA = "/api/plataforma/painel/licencas/";
function reenviarLicenca(tenant, 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();
}
aviso.textContent = "Enviando…";
return fetch(API_LICENCA + encodeURIComponent(tenant) + "/reenviar", {
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) {
aviso.textContent = (d.emailEnviado ? "Chave nova enviada para " : "Chave nova criada; 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;
reenviarLicenca(b.getAttribute("data-licenca"), $("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(); });
+365 -2
View File
@@ -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: |
<!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, nofollow">
<meta name="referrer" content="no-referrer">
<title>Convite para competição — Athletic Map</title>
<link rel="stylesheet" href="/publico/_estilo.css">
<link rel="stylesheet" href="/publico/convite.css?v=1">
</head>
<body>
<header><div class="marca">Athletic <span>Map</span></div></header>
<main>
<h1 id="titulo">Convite para competição</h1>
<p id="situacao" role="status" aria-live="polite">Abrindo o convite…</p>
<form id="formulario" hidden novalidate>
<p class="explica">
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.
</p>
<fieldset>
<legend>Seu time</legend>
<label>Nome do time *<input id="nome" maxlength="120" autocomplete="off" required></label>
<label>Escola ou clube<input id="escola" maxlength="160" autocomplete="organization"></label>
<label>Cidade<input id="cidade" maxlength="120" autocomplete="address-level2"></label>
</fieldset>
<fieldset>
<legend>Responsável pelo time</legend>
<label>Nome<input id="contatoNome" maxlength="120" autocomplete="name"></label>
<label>Telefone<input id="contatoTelefone" maxlength="40" autocomplete="tel" inputmode="tel"></label>
<label>E-mail<input id="contatoEmail" type="email" maxlength="160" autocomplete="email"></label>
</fieldset>
<fieldset>
<legend>Jogadores</legend>
<div id="jogadores"></div>
<button type="button" id="maisJogador" class="secundario">+ Jogador</button>
</fieldset>
<p id="erro" class="erro" role="alert" hidden></p>
<button type="submit" id="enviar">Enviar time</button>
</form>
</main>
<script src="/publico/desafio.js?v=1"></script>
<script src="/publico/convite.js?v=2"></script>
</body>
</html>
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.
@@ -3430,7 +3756,7 @@ data:
</main>
<script src="/publico/painel.js?v=2.87"></script>
<script src="/publico/painel.js?v=2.88"></script>
</body>
</html>
@@ -3912,7 +4238,8 @@ data:
+ (whatsapp(c.whatsapp) ? '<a class="btn claro" href="' + esc(whatsapp(c.whatsapp)) + '" target="_blank" rel="noopener noreferrer">WhatsApp</a>' : "")
+ (c.email ? '<a class="btn claro" href="mailto:' + esc(c.email) + '">E-mail</a>' : "")
+ (f.faturaVencida ? '<a class="btn claro" href="' + esc(linkSeguro(f.faturaVencida) || "#") + '" target="_blank" rel="noopener noreferrer">Fatura vencida</a>' : "")
+ "</div></header>"
+ (c.tenant ? '<button type="button" class="btn claro" data-licenca="' + esc(c.tenant) + '">Reenviar licença</button>' : "")
+ '</div><p id="licencaAviso" class="ajuda" aria-live="polite"></p></header>'
+ (alertas ? '<ul class="lista-alertas">' + alertas + "</ul>" : "")
+ '<div class="grade-ficha">'
@@ -4433,6 +4760,42 @@ 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. */
var API_LICENCA = "/api/plataforma/painel/licencas/";
function reenviarLicenca(tenant, 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();
}
aviso.textContent = "Enviando…";
return fetch(API_LICENCA + encodeURIComponent(tenant) + "/reenviar", {
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) {
aviso.textContent = (d.emailEnviado ? "Chave nova enviada para " : "Chave nova criada; 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;
reenviarLicenca(b.getAttribute("data-licenca"), $("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(); });