chore(tenants): conteudo de publico/ atualizado
This commit is contained in:
@@ -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(); });
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user