diff --git a/moldes/consolidado/96-publico-configmap.yaml b/moldes/consolidado/96-publico-configmap.yaml
index 8b2f510..e6161e7 100644
--- a/moldes/consolidado/96-publico-configmap.yaml
+++ b/moldes/consolidado/96-publico-configmap.yaml
@@ -370,6 +370,332 @@ data:
});
})();
+ convite.css: |
+ /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */
+ main { max-width: 38rem; }
+ .explica { color: var(--muted); }
+ form { display: grid; gap: 1rem; }
+ fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; }
+ legend { font-weight: 700; padding: 0 .4rem; }
+ label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; }
+ input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; }
+ input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; }
+ .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; }
+ .linha-jogador button { padding: .4rem .6rem; }
+ button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; }
+ button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; }
+ button:disabled { opacity: .55; cursor: not-allowed; }
+ .erro { color: #b42318; margin: 0; font-weight: 600; }
+ .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; }
+ #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; }
+
+ convite.html: |
+
+
+
+
+
+
+
+ Convite para competição — Athletic Map
+
+
+
+
+
+
+ Convite para competição
+ Abrindo o convite…
+
+
+
+
+
+
+
+
+ convite.js: |
+ /* ===========================================================================
+ * Convite para competição — a página que a OUTRA escola abre pelo link.
+ *
+ * Sem login: o token no endereço é o único segredo. A página pergunta ao
+ * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o
+ * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza
+ * confere e aprova.
+ *
+ * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao
+ * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o
+ * time, em `prova`. Um desafio por tentativa: ele é de uso único.
+ *
+ * Tudo o que vem do servidor entra na página com `textContent` — nunca como
+ * HTML. O script é externo porque a CSP é `script-src 'self'`.
+ * =========================================================================== */
+ (function () {
+ "use strict";
+
+ var global = window;
+
+ var TOKEN = /^[A-Za-z0-9_-]{20,64}$/;
+ var MAX_JOGADORES = 60;
+
+ var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || ""));
+ var base = "/api/public/competicoes/convites/" + encodeURIComponent(token);
+
+ var titulo = document.getElementById("titulo");
+ var situacao = document.getElementById("situacao");
+ var formulario = document.getElementById("formulario");
+ var listaJogadores = document.getElementById("jogadores");
+ var erro = document.getElementById("erro");
+ var enviar = document.getElementById("enviar");
+ var ROTULO_ENVIAR = enviar.textContent;
+
+ function recusar(texto) {
+ situacao.textContent = texto;
+ situacao.className = "invalido";
+ formulario.hidden = true;
+ }
+
+ function mostrarErro(texto) {
+ erro.textContent = texto;
+ erro.hidden = !texto;
+ }
+
+ function linhaDeJogador() {
+ var linha = document.createElement("div");
+ linha.className = "linha-jogador";
+
+ var nome = document.createElement("input");
+ nome.placeholder = "Nome do jogador";
+ nome.maxLength = 120;
+ nome.setAttribute("aria-label", "Nome do jogador");
+ nome.setAttribute("data-campo", "nome");
+
+ var camisa = document.createElement("input");
+ camisa.type = "number";
+ camisa.min = "0";
+ camisa.max = "999";
+ camisa.placeholder = "nº";
+ camisa.setAttribute("aria-label", "Número da camisa");
+ camisa.setAttribute("data-campo", "camisa");
+
+ var tirar = document.createElement("button");
+ tirar.type = "button";
+ tirar.className = "secundario";
+ tirar.textContent = "×";
+ tirar.setAttribute("aria-label", "Remover jogador");
+ tirar.addEventListener("click", function () { linha.remove(); });
+
+ linha.appendChild(nome);
+ linha.appendChild(camisa);
+ linha.appendChild(tirar);
+ listaJogadores.appendChild(linha);
+ return linha;
+ }
+
+ function valor(id) {
+ var v = document.getElementById(id).value.trim();
+ return v === "" ? null : v;
+ }
+
+ function montarCorpo() {
+ var jogadores = [];
+ var linhas = listaJogadores.querySelectorAll(".linha-jogador");
+ for (var i = 0; i < linhas.length; i++) {
+ var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim();
+ if (!nome) continue;
+ var c = linhas[i].querySelector('[data-campo="camisa"]').value;
+ jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) });
+ }
+ return {
+ nome: valor("nome"),
+ escola: valor("escola"),
+ cidade: valor("cidade"),
+ contatoNome: valor("contatoNome"),
+ contatoTelefone: valor("contatoTelefone"),
+ contatoEmail: valor("contatoEmail"),
+ jogadores: jogadores
+ };
+ }
+
+ function lerResposta(r) {
+ return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; });
+ }
+
+ function aoEnviar(ev) {
+ ev.preventDefault();
+ mostrarErro("");
+ var corpo = montarCorpo();
+ if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; }
+ if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; }
+ enviar.disabled = true;
+ enviar.textContent = "Verificando…";
+ enviar.setAttribute("aria-busy", "true");
+
+ function voltar(texto) {
+ enviar.disabled = false;
+ enviar.textContent = ROTULO_ENVIAR;
+ enviar.removeAttribute("aria-busy");
+ mostrarErro(texto);
+ }
+
+ fetch(base + "/desafio").then(lerResposta).then(function (r) {
+ if (r.status !== 200) {
+ var e = new Error("recusado");
+ e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes.";
+ throw e;
+ }
+ if (!global.AtmDesafio) throw new Error("sem-crypto");
+ return global.AtmDesafio.resolver(r.corpo);
+ }).then(function (prova) {
+ corpo.prova = prova;
+ enviar.textContent = "Enviando…";
+ return fetch(base + "/times", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(corpo)
+ }).then(lerResposta);
+ }).then(function (r) {
+ if (r.status >= 200 && r.status < 300) {
+ formulario.hidden = true;
+ titulo.textContent = "Time enviado";
+ situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página.";
+ situacao.className = "ok";
+ return;
+ }
+ voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes.");
+ }).catch(function (e) {
+ if (e && e.detalhe) { voltar(e.detalhe); return; }
+ if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) {
+ voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador.");
+ return;
+ }
+ voltar("Sem conexão. Confira a internet e tente de novo.");
+ });
+ }
+
+ function iniciar() {
+ if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; }
+ fetch(base).then(lerResposta).then(function (r) {
+ var info = r.corpo || {};
+ if (!info.valido) {
+ recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível.");
+ return;
+ }
+ titulo.textContent = "Convite: " + (info.competicao || "competição");
+ situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "")
+ + "Restam " + info.vagasRestantes + " vaga(s) neste convite.";
+ situacao.className = "";
+ formulario.hidden = false;
+ for (var i = 0; i < 5; i++) linhaDeJogador();
+ }).catch(function () {
+ recusar("Não foi possível abrir o convite agora. Tente de novo em instantes.");
+ });
+ }
+
+ document.getElementById("maisJogador").addEventListener("click", function () {
+ if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus();
+ });
+ formulario.addEventListener("submit", aoEnviar);
+ iniciar();
+ })();
+
+ desafio.js: |
+ /* ===========================================================================
+ * Verificação anti-robô das páginas públicas — prova de trabalho no estilo
+ * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map).
+ *
+ * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }.
+ * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e
+ * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são
+ * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000.
+ *
+ * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS).
+ * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o
+ * próprio hash. Script externo porque a CSP é `script-src 'self'`.
+ *
+ * O teste do servidor é DesafioDoConvite (servico-campeonato).
+ * =========================================================================== */
+ (function (global) {
+ "use strict";
+
+ var LOTE = 1000;
+ var HEX = /^[0-9a-f]{64}$/;
+
+ function bytesDoHex(h) {
+ var b = new Uint8Array(h.length / 2);
+ for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16);
+ return b;
+ }
+
+ function iguais(buf, alvo) {
+ var a = new Uint8Array(buf);
+ if (a.length !== alvo.length) return false;
+ for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false;
+ return true;
+ }
+
+ /**
+ * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d
+ * @returns {Promise<{numero:number, sal:string, assinatura:string}>}
+ * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao")
+ */
+ function resolver(d) {
+ var subtle = global.crypto && global.crypto.subtle;
+ var Codificador = global.TextEncoder;
+ if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto"));
+ if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string"
+ || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) {
+ return Promise.reject(new Error("desafio-invalido"));
+ }
+ var alvo = bytesDoHex(d.desafio);
+ var cod = new Codificador();
+
+ function lote(inicio) {
+ if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao"));
+ var fim = Math.min(inicio + LOTE - 1, d.maximo);
+ var hashes = [];
+ for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n)));
+ return Promise.all(hashes).then(function (hs) {
+ for (var i = 0; i < hs.length; i++) {
+ if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura };
+ }
+ return lote(fim + 1);
+ });
+ }
+ return lote(0);
+ }
+
+ global.AtmDesafio = { resolver: resolver };
+ })(window);
+
fontes--fontes.css: |
/* ============================================================
* As fontes da landing page, servidas POR NOS.
@@ -3430,7 +3756,7 @@ data:
-
+