diff --git a/moldes/consolidado/95-frontend-spa.yaml b/moldes/consolidado/95-frontend-spa.yaml index e09d9eb..b121ca2 100644 --- a/moldes/consolidado/95-frontend-spa.yaml +++ b/moldes/consolidado/95-frontend-spa.yaml @@ -81,10 +81,62 @@ spec: # 24 MiB medidos em 2026-09-18. 64Mi de request da folga de 2,5x. requests: { cpu: 50m, memory: 64Mi } limits: { cpu: 200m, memory: 128Mi } + volumeMounts: + - name: publico + mountPath: /usr/share/nginx/html/publico + readOnly: true readinessProbe: httpGet: { path: /, port: 80 } initialDelaySeconds: 5 periodSeconds: 10 + volumes: + - name: publico + projected: + sources: + - configMap: + name: frontend-publico-texto + items: + - { key: "_estilo.css", path: "_estilo.css" } + - { key: "consentimento.js", path: "consentimento.js" } + - { key: "fontes--fontes.css", path: "fontes/fontes.css" } + - { key: "lp-escolinhas.css", path: "lp-escolinhas.css" } + - { key: "lp-escolinhas.html", path: "lp-escolinhas.html" } + - { key: "lp-escolinhas.js", path: "lp-escolinhas.js" } + - { key: "minha-conta.css", path: "minha-conta.css" } + - { key: "minha-conta.html", path: "minha-conta.html" } + - { key: "minha-conta.js", path: "minha-conta.js" } + - { key: "painel.css", path: "painel.css" } + - { key: "painel.html", path: "painel.html" } + - { key: "painel.js", path: "painel.js" } + - { key: "planos-modulos.json", path: "planos-modulos.json" } + - { key: "preparando.html", path: "preparando.html" } + - { key: "preparando.js", path: "preparando.js" } + - { key: "privacidade.html", path: "privacidade.html" } + - { key: "termos.html", path: "termos.html" } + - configMap: + name: frontend-publico-binario + items: + - { key: "fontes--Barlow-400-latin-ext.woff2", path: "fontes/Barlow-400-latin-ext.woff2" } + - { key: "fontes--Barlow-400-latin.woff2", path: "fontes/Barlow-400-latin.woff2" } + - { key: "fontes--Barlow-500-latin-ext.woff2", path: "fontes/Barlow-500-latin-ext.woff2" } + - { key: "fontes--Barlow-500-latin.woff2", path: "fontes/Barlow-500-latin.woff2" } + - { key: "fontes--Barlow-600-latin-ext.woff2", path: "fontes/Barlow-600-latin-ext.woff2" } + - { key: "fontes--Barlow-600-latin.woff2", path: "fontes/Barlow-600-latin.woff2" } + - { key: "fontes--Barlow-700-latin-ext.woff2", path: "fontes/Barlow-700-latin-ext.woff2" } + - { key: "fontes--Barlow-700-latin.woff2", path: "fontes/Barlow-700-latin.woff2" } + - { key: "fontes--IBMPlexMono-500-latin-ext.woff2", path: "fontes/IBMPlexMono-500-latin-ext.woff2" } + - { key: "fontes--IBMPlexMono-500-latin.woff2", path: "fontes/IBMPlexMono-500-latin.woff2" } + - { key: "fontes--Poppins-500-latin-ext.woff2", path: "fontes/Poppins-500-latin-ext.woff2" } + - { key: "fontes--Poppins-500-latin.woff2", path: "fontes/Poppins-500-latin.woff2" } + - { key: "fontes--Poppins-600-latin-ext.woff2", path: "fontes/Poppins-600-latin-ext.woff2" } + - { key: "fontes--Poppins-600-latin.woff2", path: "fontes/Poppins-600-latin.woff2" } + - { key: "fontes--Poppins-700-latin-ext.woff2", path: "fontes/Poppins-700-latin-ext.woff2" } + - { key: "fontes--Poppins-700-latin.woff2", path: "fontes/Poppins-700-latin.woff2" } + - { key: "fontes--Poppins-800-latin-ext.woff2", path: "fontes/Poppins-800-latin-ext.woff2" } + - { key: "fontes--Poppins-800-latin.woff2", path: "fontes/Poppins-800-latin.woff2" } + - { key: "img--hero-escolinha.jpg", path: "img/hero-escolinha.jpg" } + - { key: "img--logo-athletic-map.png", path: "img/logo-athletic-map.png" } + - { key: "produto-dashboard.webp", path: "produto-dashboard.webp" } --- apiVersion: v1 kind: Service diff --git a/moldes/consolidado/96-publico-configmap.yaml b/moldes/consolidado/96-publico-configmap.yaml new file mode 100644 index 0000000..b452117 --- /dev/null +++ b/moldes/consolidado/96-publico-configmap.yaml @@ -0,0 +1,3525 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: frontend-publico-texto + namespace: {{TENANT}}-prod +data: + _estilo.css: | + /* Estilo mínimo, embutido nas páginas públicas. Sem fonte externa e sem CDN: + uma página que declara cuidado com dado pessoal não deveria, ela própria, + avisar um terceiro de que você a abriu. */ + :root { --navy:#0B2C3D; --orange:#F58220; --paper:#F6F5F3; --muted:#5b6b75; --line:#e3e0dc; } + * { box-sizing: border-box; } + body { + margin:0; background:var(--paper); color:var(--navy); + font-family: 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + line-height:1.65; + } + header { background:var(--navy); color:#fff; padding:1.5rem 1rem; } + header .marca { max-width:52rem; margin:0 auto; font-weight:700; letter-spacing:.02em; } + header .marca span { color:var(--orange); } + main { max-width:52rem; margin:0 auto; padding:2rem 1rem 4rem; } + h1 { font-size:1.9rem; line-height:1.2; margin:.5rem 0 .25rem; } + h2 { font-size:1.25rem; margin:2rem 0 .5rem; border-bottom:2px solid var(--line); padding-bottom:.3rem; } + h3 { font-size:1.05rem; margin:1.5rem 0 .4rem; } + .data { color:var(--muted); font-size:.9rem; margin-bottom:1.5rem; } + table { border-collapse:collapse; width:100%; margin:1rem 0; font-size:.95rem; } + th, td { border:1px solid var(--line); padding:.5rem .6rem; text-align:left; vertical-align:top; } + th { background:#fff; } + .destaque { background:#fff; border-left:4px solid var(--orange); padding:1rem 1.2rem; margin:1.5rem 0; } + .minuta { background:#fff4e5; border:2px solid var(--orange); padding:1rem 1.2rem; margin:0 0 2rem; } + .minuta strong { display:block; margin-bottom:.4rem; } + .pendente { background:#ffe8e8; border:2px dashed #c62828; padding:1rem 1.2rem; margin:1.5rem 0; } + footer { border-top:1px solid var(--line); margin-top:3rem; padding-top:1rem; color:var(--muted); font-size:.9rem; } + a { color:#1565c0; } + @media (max-width:40rem) { body { font-size:.97rem; } h1 { font-size:1.5rem; } } + + consentimento.js: | + /* =========================================================================== + * P2.9 — o portão de consentimento, antes de qualquer rastreamento de terceiro. + * + * ── POR QUE ISTO EXISTE ──────────────────────────────────────────────────── + * + * A política de privacidade que publicamos diz, em §8: + * + * "Não usamos cookies de publicidade nem de rastreamento de terceiros." + * + * No instante em que o Meta Pixel entrar no ar, essa frase vira **falsa** — e + * ela não é marketing, é declaração pública de conformidade (LGPD art. 9º). + * Preencher o `PIXEL_ID` sem resolver isto não publica um pixel: publica uma + * mentira assinada. + * + * As duas saídas eram corrigir a política ou pedir consentimento. Este arquivo + * é a segunda, que é a que mantém a promessa de pé. + * + * ── A REGRA QUE GOVERNA TUDO AQUI: FALHA FECHADA ─────────────────────────── + * + * Em toda dúvida, NÃO rastreia. Sem decisão, não rastreia. Com o localStorage + * quebrado, não rastreia. Com o banner falhando ao montar, não rastreia. + * + * O contrário — rastrear por omissão — é exatamente o defeito que a LGPD trata, + * e é o que acontece sozinho se ninguém escrever este arquivo. + * + * ── O QUE ESTE ARQUIVO NÃO COBRE ─────────────────────────────────────────── + * + * A LP carrega `fonts.googleapis.com` no ``, **antes de tudo isto**, e + * isso entrega o IP do visitante ao Google sem consentimento. Não dá para + * consertar daqui: o `` é servido no HTML. O conserto é hospedar a fonte + * junto (`publico/fontes/`), e está no diário como pendência própria. + * =========================================================================== */ + (function (global) { + 'use strict'; + + var CHAVE = 'atm.consentimento.v1'; + var ACEITO = 'aceito'; + var RECUSADO = 'recusado'; + + /* O localStorage joga exceção — e não devolve null — em janela anônima com + * cookies bloqueados. Um `getItem` solto aqui derrubaria o script inteiro, e + * com ele o formulário de lead, que é o que a página existe para fazer. */ + function ler() { + try { return global.localStorage.getItem(CHAVE); } catch (e) { return null; } + } + function gravar(valor) { + try { global.localStorage.setItem(CHAVE, valor); } catch (e) { /* segue sem lembrar */ } + } + + var decisao = ler(); + var pendentes = []; + + /* `aoAceitar` é o único caminho por onde código de terceiro entra na página. + * Quem chamar isto sem decisão tomada fica na fila; quem chamar depois de uma + * recusa nunca roda. */ + function aoAceitar(callback) { + if (decisao === ACEITO) { callback(); return; } + if (decisao === RECUSADO) { return; } + pendentes.push(callback); + } + + function decidir(valor) { + decisao = valor; + gravar(valor); + var fila = pendentes; + pendentes = []; + if (valor === ACEITO) { + for (var i = 0; i < fila.length; i++) { fila[i](); } + } + var banner = global.document.getElementById('atm-consentimento'); + if (banner && banner.parentNode) { banner.parentNode.removeChild(banner); } + } + + /* Recusar NÃO desfaz o que já carregou: o fbq já injetado continua na página + * até o recarregamento. Por isso a recusa recarrega — é a única forma honesta + * de "parar de rastrear" sem fingir que um `delete window.fbq` apaga o que já + * foi para o servidor do terceiro. */ + function recusar() { + var jaCarregou = decisao === ACEITO && (global.fbq || global.gtag); + decidir(RECUSADO); + if (jaCarregou && global.location && global.location.reload) { + global.location.reload(); + } + } + + var ESTILO_BOTAO = + 'font:600 14px/1 inherit;padding:10px 18px;border-radius:8px;cursor:pointer;border:0;'; + + /** + * Monta o banner. Não faz nada se já houver decisão — inclusive recusa: quem + * disse não uma vez não é perguntado de novo a cada visita. + */ + function montarBanner(doc) { + doc = doc || global.document; + if (decisao === ACEITO || decisao === RECUSADO) { return null; } + if (doc.getElementById('atm-consentimento')) { return null; } + + var caixa = doc.createElement('div'); + caixa.id = 'atm-consentimento'; + caixa.setAttribute('role', 'dialog'); + caixa.setAttribute('aria-label', 'Consentimento para medição'); + caixa.style.cssText = + 'position:fixed;left:0;right:0;bottom:0;z-index:9999;background:#12161c;' + + 'color:#e8edf4;padding:18px 20px;display:flex;flex-wrap:wrap;gap:14px;' + + 'align-items:center;justify-content:center;box-shadow:0 -2px 18px rgba(0,0,0,.35);' + + 'font:400 14px/1.5 system-ui,sans-serif;'; + + var texto = doc.createElement('p'); + texto.style.cssText = 'margin:0;max-width:60ch;'; + texto.innerHTML = + 'Usamos medição do Meta e do Google só para saber se nossos anúncios ' + + 'funcionam. Sem isso a página funciona igual, e o seu contato chega ' + + 'do mesmo jeito. Política de ' + + 'privacidade.'; + + /* Os dois botões são o MESMO elemento, com o mesmo tamanho e no mesmo lugar. + * Recusa escondida atrás de "preferências", ou em cinza claro sobre branco, + * é consentimento arrancado — e a ANPD trata como consentimento inválido. */ + var aceitar = doc.createElement('button'); + aceitar.type = 'button'; + aceitar.id = 'atm-consentimento-aceitar'; + aceitar.textContent = 'Aceitar'; + aceitar.style.cssText = ESTILO_BOTAO + 'background:#28c76f;color:#06210f;'; + + var negar = doc.createElement('button'); + negar.type = 'button'; + negar.id = 'atm-consentimento-recusar'; + negar.textContent = 'Recusar'; + negar.style.cssText = ESTILO_BOTAO + 'background:#39434f;color:#e8edf4;'; + + aceitar.addEventListener('click', function () { decidir(ACEITO); }); + negar.addEventListener('click', function () { decidir(RECUSADO); }); + + caixa.appendChild(texto); + caixa.appendChild(aceitar); + caixa.appendChild(negar); + (doc.body || doc.documentElement).appendChild(caixa); + return caixa; + } + + /** Para o link no rodapé: permite mudar de ideia nos dois sentidos. */ + function reabrir(doc) { + decisao = null; + try { global.localStorage.removeItem(CHAVE); } catch (e) { /* idem */ } + return montarBanner(doc); + } + + global.AtmConsentimento = { + ACEITO: ACEITO, + RECUSADO: RECUSADO, + estado: function () { return decisao; }, + aoAceitar: aoAceitar, + aceitar: function () { decidir(ACEITO); }, + recusar: recusar, + montarBanner: montarBanner, + reabrir: reabrir + }; + })(typeof window !== 'undefined' ? window : this); + + fontes--fontes.css: | + /* ============================================================ + * As fontes da landing page, servidas POR NOS. + * + * Gerado por `scripts/baixar_fontes_da_lp.py` — nao edite a mao. + * + * Existe para que o navegador do visitante nao peca nada ao Google: + * aquele pedido leva o IP dele e o Referer da nossa pagina antes de + * qualquer consentimento. Com isto, `style-src` e `font-src` da CSP + * voltam a ser so 'self'. + * + * So `latin` e `latin-ext`: a pagina e em portugues, e o + * `unicode-range` de cada bloco faria o navegador ignorar os outros. + * ============================================================ */ + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/publico/fontes/Barlow-400-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/publico/fontes/Barlow-400-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/publico/fontes/Barlow-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/publico/fontes/Barlow-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/publico/fontes/Barlow-600-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/publico/fontes/Barlow-600-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/publico/fontes/Barlow-700-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Barlow'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/publico/fontes/Barlow-700-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/publico/fontes/IBMPlexMono-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/publico/fontes/IBMPlexMono-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/publico/fontes/Poppins-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/publico/fontes/Poppins-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/publico/fontes/Poppins-600-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/publico/fontes/Poppins-600-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/publico/fontes/Poppins-700-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/publico/fontes/Poppins-700-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url(/publico/fontes/Poppins-800-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + @font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url(/publico/fontes/Poppins-800-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + lp-escolinhas.css: | + /* + * Estilo da landing page de escolinhas. + * + * MORA AQUI, E NAO NUM + + +
+ +

Estamos preparando sua conta

+

Recebemos seu pagamento e estamos comecando.

+

+ +

Pode fechar esta pagina: o acesso tambem chega por e-mail, com um + link para voce definir sua senha.

+
+ + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
" + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

" + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

") + + "
"; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
' + titulo + + 'pendente' + + '

Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

" + + '
' + + campo + + '' + + '' + + '

'; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
' + titulo + + 'documentos pendentes' + + '

O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

" + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
"; + } + return '
' + titulo + + 'documentos pendentes' + + (c.motivo ? '

' + escapar(c.motivo) + "

" : "") + + (link + ? '' + + "Enviar documentos →" + : '

O link de envio chega ao seu e-mail.

') + + "
"; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
' + titulo + '' + s[1] + "" + + (c.motivo ? '

' + escapar(c.motivo) + "

" : "") + + (s[2] ? '

' + s[2] + "

" : "") + "
"; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
+ + Painel do dono + + + +
+ +
+ + +
+

Entrar

+

+ Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

+
+ + + +
+

+
+ +

Juntando os dados do banco, do Asaas e do login…

+ +
+ +

Receita

+
+

Clientes

+
+ +
+

Precisa da sua atenção

+
    +
    + + + + +
    +
    +
    + + + + + +
    +
    + + + + + + + + + + + + + + + +
    ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
    +
    +

    Nenhum cliente com esses filtros.

    +

    Clique numa linha para abrir a ficha completa do cliente.

    +
    +
    + + +
    +
    +
    +

    Funil de vendas

    +

    Do formulário da landing page até o cliente usando o sistema.

    +
    +
    +
    +

    De onde vêm os clientes

    +

    Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

    + +
    OrigemLeadsPagaramConversão
    +
    +
    +
    +

    Leads que ainda não compraram

    +

    Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

    +
    + +
    PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
    +
    +

    Nenhum lead em aberto.

    +
    +
    + + +
    +
    +

    Recebido por mês

    +

    Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

    +
    +
    +
    +
    +

    Carteira por plano

    + +
    PlanoClientesAtivosMRRPeso no MRR
    +
    +
    +

    Perfil da carteira

    +
    +
    +
    +

    + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

    +
    + + +
    +
    +

    O que aconteceu por último

    +

    Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

    +
      +
      +
      + +
      + + + + +
      + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
      " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

      " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

      ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

      ") + + "
      "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
      ' + titulo + + 'pendente' + + '

      Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

      " + + '
      ' + + campo + + '' + + '' + + '

      '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
      ' + titulo + + 'documentos pendentes' + + '

      O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

      " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
      "; + } + return '
      ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

      ' + escapar(c.motivo) + "

      " : "") + + (link + ? '' + + "Enviar documentos →" + : '

      O link de envio chega ao seu e-mail.

      ') + + "
      "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
      ' + titulo + '' + s[1] + "" + + (c.motivo ? '

      ' + escapar(c.motivo) + "

      " : "") + + (s[2] ? '

      ' + s[2] + "

      " : "") + "
      "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
      + + Painel do dono + + + +
      + +
      + + +
      +

      Entrar

      +

      + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

      +
      + + + +
      +

      +
      + +

      Juntando os dados do banco, do Asaas e do login…

      + +
      + +

      Receita

      +
      +

      Clientes

      +
      + +
      +

      Precisa da sua atenção

      +
        +
        + + + + +
        +
        +
        + + + + + +
        +
        + + + + + + + + + + + + + + + +
        ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
        +
        +

        Nenhum cliente com esses filtros.

        +

        Clique numa linha para abrir a ficha completa do cliente.

        +
        +
        + + +
        +
        +
        +

        Funil de vendas

        +

        Do formulário da landing page até o cliente usando o sistema.

        +
        +
        +
        +

        De onde vêm os clientes

        +

        Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

        + +
        OrigemLeadsPagaramConversão
        +
        +
        +
        +

        Leads que ainda não compraram

        +

        Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

        +
        + +
        PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
        +
        +

        Nenhum lead em aberto.

        +
        +
        + + +
        +
        +

        Recebido por mês

        +

        Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

        +
        +
        +
        +
        +

        Carteira por plano

        + +
        PlanoClientesAtivosMRRPeso no MRR
        +
        +
        +

        Perfil da carteira

        +
        +
        +
        +

        + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

        +
        + + +
        +
        +

        O que aconteceu por último

        +

        Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

        +
          +
          +
          + +
          + + + + +
          + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
          " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

          " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

          ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

          ") + + "
          "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
          ' + titulo + + 'pendente' + + '

          Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

          " + + '
          ' + + campo + + '' + + '' + + '

          '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
          ' + titulo + + 'documentos pendentes' + + '

          O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

          " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
          "; + } + return '
          ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

          ' + escapar(c.motivo) + "

          " : "") + + (link + ? '' + + "Enviar documentos →" + : '

          O link de envio chega ao seu e-mail.

          ') + + "
          "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
          ' + titulo + '' + s[1] + "" + + (c.motivo ? '

          ' + escapar(c.motivo) + "

          " : "") + + (s[2] ? '

          ' + s[2] + "

          " : "") + "
          "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
          + + Painel do dono + + + +
          + +
          + + +
          +

          Entrar

          +

          + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

          +
          + + + +
          +

          +
          + +

          Juntando os dados do banco, do Asaas e do login…

          + +
          + +

          Receita

          +
          +

          Clientes

          +
          + +
          +

          Precisa da sua atenção

          +
            +
            + + + + +
            +
            +
            + + + + + +
            +
            + + + + + + + + + + + + + + + +
            ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
            +
            +

            Nenhum cliente com esses filtros.

            +

            Clique numa linha para abrir a ficha completa do cliente.

            +
            +
            + + +
            +
            +
            +

            Funil de vendas

            +

            Do formulário da landing page até o cliente usando o sistema.

            +
            +
            +
            +

            De onde vêm os clientes

            +

            Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

            + +
            OrigemLeadsPagaramConversão
            +
            +
            +
            +

            Leads que ainda não compraram

            +

            Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

            +
            + +
            PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
            +
            +

            Nenhum lead em aberto.

            +
            +
            + + +
            +
            +

            Recebido por mês

            +

            Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

            +
            +
            +
            +
            +

            Carteira por plano

            + +
            PlanoClientesAtivosMRRPeso no MRR
            +
            +
            +

            Perfil da carteira

            +
            +
            +
            +

            + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

            +
            + + +
            +
            +

            O que aconteceu por último

            +

            Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

            +
              +
              +
              + +
              + + + + +
              + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
              " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

              " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

              ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

              ") + + "
              "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
              ' + titulo + + 'pendente' + + '

              Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

              " + + '
              ' + + campo + + '' + + '' + + '

              '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
              ' + titulo + + 'documentos pendentes' + + '

              O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

              " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
              "; + } + return '
              ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

              ' + escapar(c.motivo) + "

              " : "") + + (link + ? '' + + "Enviar documentos →" + : '

              O link de envio chega ao seu e-mail.

              ') + + "
              "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
              ' + titulo + '' + s[1] + "" + + (c.motivo ? '

              ' + escapar(c.motivo) + "

              " : "") + + (s[2] ? '

              ' + s[2] + "

              " : "") + "
              "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
              + + Painel do dono + + + +
              + +
              + + +
              +

              Entrar

              +

              + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

              +
              + + + +
              +

              +
              + +

              Juntando os dados do banco, do Asaas e do login…

              + +
              + +

              Receita

              +
              +

              Clientes

              +
              + +
              +

              Precisa da sua atenção

              +
                +
                + + + + +
                +
                +
                + + + + + +
                +
                + + + + + + + + + + + + + + + +
                ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
                +
                +

                Nenhum cliente com esses filtros.

                +

                Clique numa linha para abrir a ficha completa do cliente.

                +
                +
                + + +
                +
                +
                +

                Funil de vendas

                +

                Do formulário da landing page até o cliente usando o sistema.

                +
                +
                +
                +

                De onde vêm os clientes

                +

                Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

                + +
                OrigemLeadsPagaramConversão
                +
                +
                +
                +

                Leads que ainda não compraram

                +

                Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

                +
                + +
                PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
                +
                +

                Nenhum lead em aberto.

                +
                +
                + + +
                +
                +

                Recebido por mês

                +

                Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

                +
                +
                +
                +
                +

                Carteira por plano

                + +
                PlanoClientesAtivosMRRPeso no MRR
                +
                +
                +

                Perfil da carteira

                +
                +
                +
                +

                + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

                +
                + + +
                +
                +

                O que aconteceu por último

                +

                Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

                +
                  +
                  +
                  + +
                  + + + + +
                  + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
                  " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

                  " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

                  ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

                  ") + + "
                  "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
                  ' + titulo + + 'pendente' + + '

                  Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

                  " + + '
                  ' + + campo + + '' + + '' + + '

                  '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
                  ' + titulo + + 'documentos pendentes' + + '

                  O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

                  " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
                  "; + } + return '
                  ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

                  ' + escapar(c.motivo) + "

                  " : "") + + (link + ? '' + + "Enviar documentos →" + : '

                  O link de envio chega ao seu e-mail.

                  ') + + "
                  "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
                  ' + titulo + '' + s[1] + "" + + (c.motivo ? '

                  ' + escapar(c.motivo) + "

                  " : "") + + (s[2] ? '

                  ' + s[2] + "

                  " : "") + "
                  "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
                  + + Painel do dono + + + +
                  + +
                  + + +
                  +

                  Entrar

                  +

                  + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

                  +
                  + + + +
                  +

                  +
                  + +

                  Juntando os dados do banco, do Asaas e do login…

                  + +
                  + +

                  Receita

                  +
                  +

                  Clientes

                  +
                  + +
                  +

                  Precisa da sua atenção

                  +
                    +
                    + + + + +
                    +
                    +
                    + + + + + +
                    +
                    + + + + + + + + + + + + + + + +
                    ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
                    +
                    +

                    Nenhum cliente com esses filtros.

                    +

                    Clique numa linha para abrir a ficha completa do cliente.

                    +
                    +
                    + + +
                    +
                    +
                    +

                    Funil de vendas

                    +

                    Do formulário da landing page até o cliente usando o sistema.

                    +
                    +
                    +
                    +

                    De onde vêm os clientes

                    +

                    Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

                    + +
                    OrigemLeadsPagaramConversão
                    +
                    +
                    +
                    +

                    Leads que ainda não compraram

                    +

                    Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

                    +
                    + +
                    PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
                    +
                    +

                    Nenhum lead em aberto.

                    +
                    +
                    + + +
                    +
                    +

                    Recebido por mês

                    +

                    Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

                    +
                    +
                    +
                    +
                    +

                    Carteira por plano

                    + +
                    PlanoClientesAtivosMRRPeso no MRR
                    +
                    +
                    +

                    Perfil da carteira

                    +
                    +
                    +
                    +

                    + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

                    +
                    + + +
                    +
                    +

                    O que aconteceu por último

                    +

                    Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

                    +
                      +
                      +
                      + +
                      + + + + +
                      + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
                      " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

                      " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

                      ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

                      ") + + "
                      "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
                      ' + titulo + + 'pendente' + + '

                      Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

                      " + + '
                      ' + + campo + + '' + + '' + + '

                      '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
                      ' + titulo + + 'documentos pendentes' + + '

                      O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

                      " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
                      "; + } + return '
                      ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

                      ' + escapar(c.motivo) + "

                      " : "") + + (link + ? '' + + "Enviar documentos →" + : '

                      O link de envio chega ao seu e-mail.

                      ') + + "
                      "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
                      ' + titulo + '' + s[1] + "" + + (c.motivo ? '

                      ' + escapar(c.motivo) + "

                      " : "") + + (s[2] ? '

                      ' + s[2] + "

                      " : "") + "
                      "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
                      + + Painel do dono + + + +
                      + +
                      + + +
                      +

                      Entrar

                      +

                      + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

                      +
                      + + + +
                      +

                      +
                      + +

                      Juntando os dados do banco, do Asaas e do login…

                      + +
                      + +

                      Receita

                      +
                      +

                      Clientes

                      +
                      + +
                      +

                      Precisa da sua atenção

                      +
                        +
                        + + + + +
                        +
                        +
                        + + + + + +
                        +
                        + + + + + + + + + + + + + + + +
                        ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
                        +
                        +

                        Nenhum cliente com esses filtros.

                        +

                        Clique numa linha para abrir a ficha completa do cliente.

                        +
                        +
                        + + +
                        +
                        +
                        +

                        Funil de vendas

                        +

                        Do formulário da landing page até o cliente usando o sistema.

                        +
                        +
                        +
                        +

                        De onde vêm os clientes

                        +

                        Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

                        + +
                        OrigemLeadsPagaramConversão
                        +
                        +
                        +
                        +

                        Leads que ainda não compraram

                        +

                        Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

                        +
                        + +
                        PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
                        +
                        +

                        Nenhum lead em aberto.

                        +
                        +
                        + + +
                        +
                        +

                        Recebido por mês

                        +

                        Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

                        +
                        +
                        +
                        +
                        +

                        Carteira por plano

                        + +
                        PlanoClientesAtivosMRRPeso no MRR
                        +
                        +
                        +

                        Perfil da carteira

                        +
                        +
                        +
                        +

                        + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

                        +
                        + + +
                        +
                        +

                        O que aconteceu por último

                        +

                        Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

                        +
                          +
                          +
                          + +
                          + + + + +
                          + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
                          " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

                          " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

                          ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

                          ") + + "
                          "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
                          ' + titulo + + 'pendente' + + '

                          Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

                          " + + '
                          ' + + campo + + '' + + '' + + '

                          '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
                          ' + titulo + + 'documentos pendentes' + + '

                          O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

                          " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
                          "; + } + return '
                          ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

                          ' + escapar(c.motivo) + "

                          " : "") + + (link + ? '' + + "Enviar documentos →" + : '

                          O link de envio chega ao seu e-mail.

                          ') + + "
                          "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
                          ' + titulo + '' + s[1] + "" + + (c.motivo ? '

                          ' + escapar(c.motivo) + "

                          " : "") + + (s[2] ? '

                          ' + s[2] + "

                          " : "") + "
                          "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
                          + + Painel do dono + + + +
                          + +
                          + + +
                          +

                          Entrar

                          +

                          + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

                          +
                          + + + +
                          +

                          +
                          + +

                          Juntando os dados do banco, do Asaas e do login…

                          + +
                          + +

                          Receita

                          +
                          +

                          Clientes

                          +
                          + +
                          +

                          Precisa da sua atenção

                          +
                            +
                            + + + + +
                            +
                            +
                            + + + + + +
                            +
                            + + + + + + + + + + + + + + + +
                            ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
                            +
                            +

                            Nenhum cliente com esses filtros.

                            +

                            Clique numa linha para abrir a ficha completa do cliente.

                            +
                            +
                            + + +
                            +
                            +
                            +

                            Funil de vendas

                            +

                            Do formulário da landing page até o cliente usando o sistema.

                            +
                            +
                            +
                            +

                            De onde vêm os clientes

                            +

                            Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

                            + +
                            OrigemLeadsPagaramConversão
                            +
                            +
                            +
                            +

                            Leads que ainda não compraram

                            +

                            Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

                            +
                            + +
                            PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
                            +
                            +

                            Nenhum lead em aberto.

                            +
                            +
                            + + +
                            +
                            +

                            Recebido por mês

                            +

                            Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

                            +
                            +
                            +
                            +
                            +

                            Carteira por plano

                            + +
                            PlanoClientesAtivosMRRPeso no MRR
                            +
                            +
                            +

                            Perfil da carteira

                            +
                            +
                            +
                            +

                            + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

                            +
                            + + +
                            +
                            +

                            O que aconteceu por último

                            +

                            Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

                            +
                              +
                              +
                              + +
                              + + + + +
                              + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
                              " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

                              " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

                              ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

                              ") + + "
                              "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
                              ' + titulo + + 'pendente' + + '

                              Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

                              " + + '
                              ' + + campo + + '' + + '' + + '

                              '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
                              ' + titulo + + 'documentos pendentes' + + '

                              O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

                              " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
                              "; + } + return '
                              ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

                              ' + escapar(c.motivo) + "

                              " : "") + + (link + ? '' + + "Enviar documentos →" + : '

                              O link de envio chega ao seu e-mail.

                              ') + + "
                              "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
                              ' + titulo + '' + s[1] + "" + + (c.motivo ? '

                              ' + escapar(c.motivo) + "

                              " : "") + + (s[2] ? '

                              ' + s[2] + "

                              " : "") + "
                              "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
                              + + Painel do dono + + + +
                              + +
                              + + +
                              +

                              Entrar

                              +

                              + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

                              +
                              + + + +
                              +

                              +
                              + +

                              Juntando os dados do banco, do Asaas e do login…

                              + +
                              + +

                              Receita

                              +
                              +

                              Clientes

                              +
                              + +
                              +

                              Precisa da sua atenção

                              +
                                +
                                + + + + +
                                +
                                +
                                + + + + + +
                                +
                                + + + + + + + + + + + + + + + +
                                ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
                                +
                                +

                                Nenhum cliente com esses filtros.

                                +

                                Clique numa linha para abrir a ficha completa do cliente.

                                +
                                +
                                + + +
                                +
                                +
                                +

                                Funil de vendas

                                +

                                Do formulário da landing page até o cliente usando o sistema.

                                +
                                +
                                +
                                +

                                De onde vêm os clientes

                                +

                                Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

                                + +
                                OrigemLeadsPagaramConversão
                                +
                                +
                                +
                                +

                                Leads que ainda não compraram

                                +

                                Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

                                +
                                + +
                                PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
                                +
                                +

                                Nenhum lead em aberto.

                                +
                                +
                                + + +
                                +
                                +

                                Recebido por mês

                                +

                                Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

                                +
                                +
                                +
                                +
                                +

                                Carteira por plano

                                + +
                                PlanoClientesAtivosMRRPeso no MRR
                                +
                                +
                                +

                                Perfil da carteira

                                +
                                +
                                +
                                +

                                + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

                                +
                                + + +
                                +
                                +

                                O que aconteceu por último

                                +

                                Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

                                +
                                  +
                                  +
                                  + +
                                  + + + + +
                                  + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um + + + + + lp-escolinhas.js: | + /* =========================================================================== + * O script da landing page. + * + * ── POR QUE ELE NAO E MAIS INLINE ────────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'` — SEM `'unsafe-inline'` e sem + * nonce. Um ` + + + + minha-conta.js: | + /* O painel do comprador. + * + * ── AUTHORIZATION CODE COM PKCE, E NAO IMPLICIT ────────────────────────────── + * + * O fluxo implicito devolve o token na URL — e URL vai para historico do + * navegador, para log de servidor e para o `Referer` de qualquer link clicado + * depois. PKCE troca um codigo de uso unico por token num POST, e o codigo + * sozinho nao vale nada sem o `code_verifier` que nunca saiu desta aba. + * + * ── O TOKEN NAO E GUARDADO ─────────────────────────────────────────────────── + * + * Ele vive em memoria, nesta aba, ate ela fechar. Recarregar a pagina refaz o + * login — e como o Keycloak mantem a sessao dele, isso e invisivel: um ida e + * volta sem digitar nada. Guardar em `sessionStorage` economizaria esse ida e + * volta e deixaria um token de gente legivel por qualquer script da pagina. + * + * ── SEM BIBLIOTECA ────────────────────────────────────────────────────────── + * + * PKCE sao trinta linhas com `crypto.subtle`, que existe em todo navegador que + * fala HTTPS. Trazer uma biblioteca de OIDC para isto seria trazer superficie + * que ninguem le para resolver o que cabe nesta tela. + */ + (function () { + "use strict"; + + var CONFIG = { + // Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita + // para entrar, e tem de bater com o `connect-src` do bloco desta pagina + // no nginx.conf.template. + EMISSOR: "https://auth-plataforma.athleticmap.com/realms/clientes", + CLIENTE: "painel-do-cliente", + RETORNO: location.origin + "/minha-conta", + API: "/api/cliente/meus-produtos", + COBRANCA: "/api/cliente/cobranca" + }; + + var CHAVE_VERIFIER = "atm.conta.verifier"; + + /* A UNICA saida da pagina passa por aqui. + No navegador e `location.assign`. Existe como funcao porque o jsdom torna + `location` e todos os metodos dele imutaveis — sem esta costura, nenhum + teste consegue ver PARA ONDE o login manda, que e justamente o que precisa + ser provado (code + PKCE, e nunca token na URL). */ + function navegar(url) { + if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; } + location.assign(url); + } + var token = null; // so em memoria + + var porta = document.getElementById("porta"); + var carregando = document.getElementById("carregando"); + var conteudo = document.getElementById("conteudo"); + var produtos = document.getElementById("produtos"); + var nenhum = document.getElementById("nenhum"); + var caixaErro = document.getElementById("erro"); + var erroTexto = document.getElementById("erroTexto"); + var botaoSair = document.getElementById("sair"); + + function mostrar(qual) { + [porta, carregando, conteudo, caixaErro].forEach(function (s) { + if (s) s.classList.add("oculto"); + }); + if (qual) qual.classList.remove("oculto"); + if (botaoSair) botaoSair.classList.toggle("oculto", qual !== conteudo); + } + + function falhar(mensagem) { + if (erroTexto) erroTexto.textContent = mensagem; + mostrar(caixaErro); + } + + /* ── PKCE ───────────────────────────────────────────────────────────────── */ + + function aleatorio(tamanho) { + var bytes = new Uint8Array(tamanho); + crypto.getRandomValues(bytes); + return base64url(bytes); + } + + function base64url(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function desafio(verifier) { + return crypto.subtle + .digest("SHA-256", new TextEncoder().encode(verifier)) + .then(function (buf) { return base64url(new Uint8Array(buf)); }); + } + + function entrar() { + if (CONFIG.EMISSOR.indexOf("PREENCHER") === 0) { + falhar("O login ainda nao esta configurado neste ambiente."); + return; + } + var verifier = aleatorio(64); + try { sessionStorage.setItem(CHAVE_VERIFIER, verifier); } catch (e) { /* aba privada */ } + + desafio(verifier).then(function (challenge) { + var p = new URLSearchParams({ + client_id: CONFIG.CLIENTE, + response_type: "code", + scope: "openid email profile", + redirect_uri: CONFIG.RETORNO, + code_challenge: challenge, + code_challenge_method: "S256" + }); + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/auth?" + p.toString()); + }); + } + + function trocarCodigo(codigo) { + var verifier = null; + try { verifier = sessionStorage.getItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + if (!verifier) { + // Sem o verifier, o codigo nao vale nada — e e assim que tem de ser. + // Acontece quando alguem cola a URL de retorno noutra aba. + falhar("O login expirou. Tente entrar de novo."); + return Promise.resolve(); + } + try { sessionStorage.removeItem(CHAVE_VERIFIER); } catch (e) { /* idem */ } + + return fetch(CONFIG.EMISSOR + "/protocol/openid-connect/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CONFIG.CLIENTE, + code: codigo, + redirect_uri: CONFIG.RETORNO, + code_verifier: verifier + }) + }).then(function (r) { + if (!r.ok) throw new Error("troca do codigo"); + return r.json(); + }).then(function (d) { + token = d.access_token; + // O codigo sai da URL antes de qualquer outra coisa: ele e de uso unico, + // mas ficaria no historico e no `Referer` do proximo clique. + history.replaceState({}, "", location.pathname); + return carregar(); + }); + } + + /* ── OS PRODUTOS ────────────────────────────────────────────────────────── */ + + function escapar(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + var SITUACAO = { + ATIVA: ["ok", "ativa"], + EM_ATRASO: ["alerta", "pagamento em atraso"], + SUSPENSA: ["atencao", "suspensa"], + CANCELADA: ["neutro", "cancelada"], + PREPARANDO: ["neutro", "preparando sua conta"] + }; + + /* O plano vira CLASSE do cartao — cada plano tem a sua cor. Filtrado para + letras e hifen: o valor vem do banco, e um nome de plano com aspas ou + espaco viraria atributo HTML. Plano desconhecido cai em `plano-outro`. */ + function classeDoPlano(plano) { + var c = String(plano || "").toLowerCase().replace(/[^a-z-]/g, ""); + return c ? "plano-" + c : "plano-outro"; + } + + /* O nome que o cliente le. `teste-interno` e nome de maquina. */ + function nomeDoPlano(plano) { + var n = String(plano || "").toLowerCase(); + if (n.indexOf("teste") === 0) return "Teste"; + return n ? n.charAt(0).toUpperCase() + n.slice(1) : "—"; + } + + function cartao(p) { + var s = SITUACAO[p.situacao] || ["neutro", String(p.situacao || "").toLowerCase()]; + var podeEntrar = p.endereco && p.situacao === "ATIVA"; + return '
                                  " + + 'Plano ' + escapar(nomeDoPlano(p.plano)) + "" + + "

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

                                  " + + '' + s[1] + "" + + (podeEntrar + ? 'Entrar →' + : '

                                  ' + (p.situacao === "PREPARANDO" + ? "Assim que ficar pronta, o endereco aparece aqui." + : "Regularize o pagamento para voltar a entrar.") + "

                                  ") + + "
                                  "; + } + + function carregar() { + return fetch(CONFIG.API, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("sessao"); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (lista) { + enderecos = {}; + (lista || []).forEach(function (p) { if (p.tenant) enderecos[p.tenant] = p.endereco; }); + produtos.innerHTML = (lista || []).map(cartao).join(""); + nenhum.classList.toggle("oculto", (lista || []).length > 0); + mostrar(conteudo); + return carregarCobranca(); + }); + } + + /* ── A COBRANCA DOS ALUNOS (subconta Asaas) ───────────────────────────────── + * + * Um alerta DENTRO do cartao do produto, porque a pendencia e daquela + * escolinha. Falhar aqui nao derruba a tela: os produtos ja estao nela, e a + * cobranca e um complemento — o erro fica so no console. */ + + var TIPOS_DE_EMPRESA = [ + ["MEI", "MEI"], ["LIMITED", "Limitada (LTDA)"], + ["INDIVIDUAL", "Empresário individual"], ["ASSOCIATION", "Associação"] + ]; + + var ROTULO_DO_CAMPO = { + nascimento: "data de nascimento", tipoDeEmpresa: "tipo de empresa", + faturamentoMensal: "faturamento mensal", celular: "celular", endereco: "endereco", + numero: "numero", bairro: "bairro", cep: "CEP", nome: "nome", email: "e-mail", + cpfCnpj: "CPF/CNPJ" + }; + + /* So link https: o valor vem de fora, e um `javascript:` num href executa. */ + function linkSeguro(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : null; + } + + /* Os enderecos dos sistemas, por tenant — para o link vencido apontar ao + sistema certo. Preenchido em `carregar`. */ + var enderecos = {}; + + /* `AAAA-MM-DD` (ou instante ISO) ja passou? Comparacao de texto no dia, sem + `new Date` para a data pura: meia-noite UTC vira o dia anterior aqui. */ + function vencido(quando) { + if (!quando) return false; + var hoje = new Date(); + var h = hoje.getFullYear() + "-" + ("0" + (hoje.getMonth() + 1)).slice(-2) + "-" + ("0" + hoje.getDate()).slice(-2); + return String(quando).slice(0, 10) < h; + } + + function alertaDeCobranca(c) { + var titulo = 'Cobrança dos alunos'; + if (c.situacao === "AGUARDANDO_DADOS") { + var campo = c.pessoa === "JURIDICA" + ? '" + : ''; + return '
                                  ' + titulo + + 'pendente' + + '

                                  Para cobrar mensalidades pelo sistema, falta abrir a sua conta de ' + + "recebimento. Os outros dados vêm do seu pagamento.

                                  " + + '
                                  ' + + campo + + '' + + '' + + '

                                  '; + } + if (c.situacao === "DOCUMENTOS_PENDENTES") { + // O link do Asaas VENCE, e a plataforma nao consegue gerar outro: so o + // sistema da propria escolinha tem a chave da conta. Vencido, o caminho e + // a tela inicial do sistema, que traz sempre um link novo. + var link = vencido(c.linkExpiraEm) ? null : linkSeguro(c.linkDocumentos); + var sistema = linkSeguro(enderecos[c.tenant]); + if (!link) { + return '
                                  ' + titulo + + 'documentos pendentes' + + '

                                  O link de envio não vale mais aqui. A tela inicial do seu sistema traz ' + + "sempre um link novo.

                                  " + + (sistema ? '' + + "Abrir o meu sistema →" : "") + + "
                                  "; + } + return '
                                  ' + titulo + + 'documentos pendentes' + + (c.motivo ? '

                                  ' + escapar(c.motivo) + "

                                  " : "") + + (link + ? '' + + "Enviar documentos →" + : '

                                  O link de envio chega ao seu e-mail.

                                  ') + + "
                                  "; + } + var s = { + EM_ANALISE: ["neutro", "documentos em análise", + "Avisamos por e-mail quando a análise terminar."], + APROVADA: ["ok", "ativa", ""], + RECUSADA: ["atencao", "não aprovada", + "Fale com a gente em contato@athleticmap.com."] + }[c.situacao]; + if (!s) return ""; + return '
                                  ' + titulo + '' + s[1] + "" + + (c.motivo ? '

                                  ' + escapar(c.motivo) + "

                                  " : "") + + (s[2] ? '

                                  ' + s[2] + "

                                  " : "") + "
                                  "; + } + + function carregarCobranca() { + return fetch(CONFIG.COBRANCA, { headers: { Authorization: "Bearer " + token } }) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (lista) { + (lista || []).forEach(function (c) { + var alvo = null; + produtos.querySelectorAll("[data-tenant]").forEach(function (el) { + if (el.getAttribute("data-tenant") === c.tenant) alvo = el; + }); + if (!alvo) return; + var velho = alvo.querySelector(".cobranca"); + if (velho) velho.remove(); + alvo.insertAdjacentHTML("beforeend", alertaDeCobranca(c)); + }); + }) + .catch(function (e) { if (window.console) console.warn("cobranca:", e); }); + } + + function enviarCobranca(form) { + var erro = form.querySelector(".erro-form"); + var botao = form.querySelector("button"); + var dados = {}; + ["nascimento", "tipoDeEmpresa", "faturamentoMensal"].forEach(function (n) { + var el = form.elements[n]; + if (el && el.value) dados[n] = n === "faturamentoMensal" ? Number(el.value) : el.value; + }); + botao.disabled = true; + erro.classList.add("oculto"); + return fetch(CONFIG.COBRANCA + "/" + encodeURIComponent(form.getAttribute("data-tenant")), { + method: "POST", + headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" }, + body: JSON.stringify(dados) + }).then(function (r) { + if (r.ok || r.status === 409) return carregarCobranca(); + if (r.status === 422) { + return r.json().then(function (d) { + throw new Error("Confira: " + (d.faltando || []).map(function (f) { + return ROTULO_DO_CAMPO[f] || f; + }).join(", ") + "."); + }); + } + throw new Error("Nao consegui enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + erro.textContent = e.message; + erro.classList.remove("oculto"); + botao.disabled = false; + }); + } + + /* Delegacao, e nao `onsubmit=`: a CSP recusa manipulador embutido, e os + formularios nascem depois deste codigo. */ + if (produtos) produtos.addEventListener("submit", function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains("form-cobranca")) return; + ev.preventDefault(); + enviarCobranca(form); + }); + + /* ── ENTRADA ────────────────────────────────────────────────────────────── */ + + function comecar() { + var codigo = new URLSearchParams(location.search).get("code"); + var erroOidc = new URLSearchParams(location.search).get("error"); + + if (erroOidc) { + history.replaceState({}, "", location.pathname); + falhar("O login nao foi concluido."); + return; + } + if (codigo) { + mostrar(carregando); + trocarCodigo(codigo).catch(function () { + falhar("Nao consegui concluir o login. Tente de novo."); + }); + return; + } + // Sem token e sem codigo: a porta. NAO redireciona sozinho — um redirect + // automatico faz quem chegou por engano sair da pagina sem entender. + mostrar(porta); + } + + var botaoEntrar = document.getElementById("entrar"); + if (botaoEntrar) botaoEntrar.addEventListener("click", entrar); + + var botaoTentar = document.getElementById("tentar"); + if (botaoTentar) botaoTentar.addEventListener("click", function () { + token = null; + mostrar(porta); + }); + + if (botaoSair) botaoSair.addEventListener("click", function () { + token = null; + // Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo + // "Entrar" passar direto, e quem clicou em sair esperava o contrario. + if (CONFIG.EMISSOR.indexOf("PREENCHER") !== 0) { + navegar(CONFIG.EMISSOR + "/protocol/openid-connect/logout?" + + new URLSearchParams({ client_id: CONFIG.CLIENTE, + post_logout_redirect_uri: CONFIG.RETORNO }).toString()); + } else { + mostrar(porta); + } + }); + + comecar(); + })(); + + painel.css: | + /* O painel do dono, na identidade do escolinha: papel, azul-marinho e laranja, + * titulos em Poppins, e as cores de plano iguais as do painel do comprador. + * + * NADA de `style=` no HTML: a CSP da plataforma recusa estilo embutido. As + * barras dos graficos recebem largura/altura pelo CSSOM, no `painel.js`. */ + + :root { + --orange: #F58220; + --orange-600: #D96C0F; + --orange-claro: #FFF1E3; + --navy: #0B2C3D; + --azul: #1F5B7A; + --paper: #F6F5F3; + --line: #E7E5E1; + --muted: #6B6B6B; + --branco: #fff; + --ok: #0f8a4d; + --alerta: #b45309; + --perigo: #b42318; + } + + * { box-sizing: border-box; } + + body { + margin: 0; background: var(--paper); color: var(--navy); + font: 15px/1.5 'Barlow', 'Inter', 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif; + } + body.sem-rolagem { overflow: hidden; } + h1, h2, h3, .bloco .numero { font-family: 'Poppins', 'Barlow', system-ui, sans-serif; letter-spacing: -.01em; } + a { color: var(--orange-600); } + .oculto { display: none !important; } + .vermelho { color: var(--perigo); } + .alerta-texto { color: var(--alerta); } + + /* ── topo ─────────────────────────────────────────────────────────────────── */ + .topo { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 14px; + padding: 12px 28px; background: var(--branco); border-bottom: 3px solid var(--orange); + } + .topo .logo { display: block; height: 32px; width: auto; } + .topo .titulo { color: var(--muted); font-size: 14px; padding-left: 14px; border-left: 1px solid var(--line); } + .topo .apurado { margin-left: auto; color: var(--muted); font-size: 13px; } + .topo .acao, .topo .sair { + background: none; border: 1px solid var(--line); color: var(--navy); + border-radius: 10px; padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; + } + .topo .sair:only-of-type, .topo .acao.oculto + .sair { margin-left: auto; } + .topo .acao:hover, .topo .sair:hover { border-color: var(--orange); color: var(--orange-600); } + .topo .acao:disabled { opacity: .6; cursor: wait; } + + .wrap { max-width: 1320px; margin: 0 auto; padding: 22px 28px 48px; } + + /* ── cartoes ──────────────────────────────────────────────────────────────── */ + .cartao { + background: var(--branco); border: 1px solid var(--line); border-radius: 16px; + padding: 22px; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(11,44,61,.05); + } + .cartao h2 { margin: 0 0 4px; font-size: 18px; font-weight: 700; } + .cartao-topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .contador { + background: var(--orange); color: var(--branco); border-radius: 999px; + font-size: 12px; font-weight: 700; padding: 2px 10px; + } + .contador:empty { display: none; } + + .porta { max-width: 420px; margin: 50px auto; } + .porta label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 5px; } + .porta input { width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; font: inherit; } + .porta input:focus { outline: 2px solid var(--orange); border-color: transparent; } + + .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + margin-top: 14px; padding: 10px 18px; border: 0; border-radius: 10px; + background: var(--orange); color: var(--branco); font: inherit; font-weight: 700; + cursor: pointer; text-decoration: none; + } + .porta .btn { width: 100%; } + .btn:hover { background: var(--orange-600); } + .btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); } + .btn.claro:hover { border-color: var(--orange); color: var(--orange-600); } + + .ajuda { color: var(--muted); font-size: 13px; margin: 4px 0 0; } + .erro { color: var(--perigo); font-size: 13px; margin-top: 12px; } + .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } + + /* ── indicadores ──────────────────────────────────────────────────────────── */ + .secao { + font-size: 12px; font-family: 'IBM Plex Mono', monospace; font-weight: 500; + letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 6px 0 10px; + } + .indicadores { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); margin-bottom: 18px; } + .bloco { + background: var(--branco); border: 1px solid var(--line); border-top: 4px solid var(--line); + border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 2px; + } + .bloco .rotulo { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + .bloco .numero { font-size: 24px; font-weight: 700; line-height: 1.2; } + .bloco .detalhe { font-size: 12px; color: var(--muted); } + .bloco.destaque { background: var(--navy); color: var(--branco); border-color: var(--navy); border-top-color: var(--orange); } + .bloco.destaque .rotulo, .bloco.destaque .detalhe { color: #c9d5de; } + .bloco.ok { border-top-color: var(--ok); } + .bloco.alerta { border-top-color: var(--alerta); } + .bloco.atencao { border-top-color: var(--perigo); } + .bloco.atencao .numero { color: var(--perigo); } + + /* ── alertas ──────────────────────────────────────────────────────────────── */ + .alertas { border-left: 5px solid var(--perigo); } + .lista-alertas { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } + .alerta-item { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 12px; + border-radius: 10px; background: #eef1f6; + } + .alerta-item[data-assinatura] { cursor: pointer; } + .alerta-item[data-assinatura]:hover { filter: brightness(.97); } + .alerta-item .marca { flex: 0 0 9px; height: 9px; border-radius: 50%; margin-top: 6px; background: var(--muted); } + .alerta-item.critico { background: #fdecea; } + .alerta-item.critico .marca { background: var(--perigo); } + .alerta-item.alerta { background: #fdf3e6; } + .alerta-item.alerta .marca { background: var(--alerta); } + .alerta-item .quem { color: var(--muted); font-size: 13px; } + + /* ── abas ─────────────────────────────────────────────────────────────────── */ + .abas { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 6px 0 16px; overflow-x: auto; } + .aba { + background: none; border: 0; border-bottom: 3px solid transparent; padding: 10px 16px; + font: inherit; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; + } + .aba:hover { color: var(--navy); } + .aba.ativa { color: var(--navy); border-bottom-color: var(--orange); } + + .grade-2 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } + .grade-2 > .cartao { margin-bottom: 0; } + .grade-2 { margin-bottom: 18px; } + + /* ── filtros e tabela ─────────────────────────────────────────────────────── */ + .filtros { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } + .filtros input[type=search], .filtros select { + font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid var(--line); + border-radius: 10px; background: var(--branco); color: var(--navy); + } + .filtros input[type=search] { flex: 1 1 260px; } + .filtro { font-size: 13px; color: var(--muted); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } + + .tabela-rolagem { overflow-x: auto; } + .tabela { width: 100%; border-collapse: collapse; font-size: 14px; } + .tabela th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; + color: var(--muted); border-bottom: 1px solid var(--line); padding: 8px 10px; white-space: nowrap; + } + .tabela td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + .tabela .num { text-align: right; white-space: nowrap; } + .tabela tr.clicavel { cursor: pointer; } + .tabela tr.clicavel:hover td { background: #fbfaf8; } + .tabela tr.clicavel:focus { outline: 2px solid var(--orange); outline-offset: -2px; } + .tabela tr.linha-critico td:first-child { box-shadow: inset 4px 0 0 var(--perigo); } + .tabela tr.linha-alerta td:first-child { box-shadow: inset 4px 0 0 var(--alerta); } + .tabela tr.destaque td { background: #fff5f4; } + .tabela tr.teste td { color: var(--muted); } + .zap { font-weight: 600; text-decoration: none; } + .zap::before { content: "● "; color: #25D366; } + .ponto-on { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); margin-right: 5px; } + + /* ── selos e etiquetas de plano ───────────────────────────────────────────── */ + .selo { + display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; + } + .selo.ok { background: #e7f6ee; color: var(--ok); } + .selo.alerta { background: #fdf3e6; color: var(--alerta); } + .selo.atencao { background: #fdecea; color: var(--perigo); } + .selo.neutro { background: #eef1f6; color: var(--muted); } + + .plano-outro { --plano: var(--orange); --plano-claro: var(--orange-claro); --plano-escuro: var(--orange-600); } + .plano-bronze { --plano: #C0793A; --plano-claro: #F8EBDD; --plano-escuro: #8A4E1F; } + .plano-prata { --plano: #9AA3AD; --plano-claro: #F1F3F5; --plano-escuro: #5E6873; } + .plano-ouro { --plano: #D9A21B; --plano-claro: #FBF3D9; --plano-escuro: #9A6F05; } + .plano-institucional { --plano: #1F5B7A; --plano-claro: #E4EEF3; --plano-escuro: var(--navy); } + .plano-teste-interno { --plano: #8A94A0; --plano-claro: #F2F3F5; --plano-escuro: #5B6470; } + .etiqueta { + display: inline-block; padding: 2px 10px; border-radius: 8px; + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; + background: var(--plano-claro, var(--orange-claro)); color: var(--plano-escuro, var(--orange-600)); + border: 1px solid var(--plano, var(--orange)); + } + + /* ── barras (funil, planos, perfil) ───────────────────────────────────────── */ + .barras { display: grid; gap: 10px; margin-top: 14px; } + .barra-linha { display: grid; grid-template-columns: 200px 1fr 90px; gap: 10px; align-items: center; font-size: 14px; } + .barra-linha.compacta { grid-template-columns: 130px 1fr 34px; font-size: 13px; } + .barra-rotulo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .barra-valor { text-align: right; font-weight: 700; } + .barra-trilho { display: inline-block; height: 12px; background: #eef1f6; border-radius: 999px; overflow: hidden; width: 100%; } + .barra-trilho.fino { height: 8px; width: 90px; vertical-align: middle; } + .barra-cheia { display: block; height: 100%; width: 0; background: var(--orange); border-radius: 999px; transition: width .4s; } + .barra-cheia.azul { background: var(--azul); } + + .distribuicao { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 8px; } + .dist h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600; } + .dist .barra-linha { margin-bottom: 6px; } + + /* ── colunas (recebido por mes) ───────────────────────────────────────────── */ + .colunas { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; align-items: end; height: 230px; margin-top: 16px; } + .coluna { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; } + .coluna-valor { font-size: 12px; font-weight: 700; } + .coluna-trilho { flex: 1; width: 100%; max-width: 70px; display: flex; align-items: flex-end; gap: 4px; } + .coluna-bruto, .coluna-liquido { flex: 1; height: 0; border-radius: 6px 6px 0 0; transition: height .4s; min-height: 2px; } + .coluna-bruto { background: var(--orange); } + .coluna-liquido { background: var(--azul); } + .coluna-rotulo { font-size: 12px; color: var(--muted); } + + /* ── linha do tempo ───────────────────────────────────────────────────────── */ + .linha-do-tempo { list-style: none; margin: 10px 0 0; padding: 0; } + .linha-do-tempo li { + display: grid; grid-template-columns: 130px 1fr; gap: 12px; padding: 9px 0; + border-bottom: 1px solid var(--line); font-size: 14px; + } + .linha-do-tempo time { color: var(--muted); font-size: 13px; } + .linha-do-tempo li.com-erro { background: #fff5f4; } + + /* ── a ficha ──────────────────────────────────────────────────────────────── */ + .ficha { + position: fixed; inset: 0; z-index: 20; background: rgba(11,44,61,.45); + display: flex; justify-content: flex-end; + } + .ficha-caixa { + position: relative; width: min(980px, 100%); height: 100%; overflow-y: auto; + background: var(--paper); padding: 0 0 40px; box-shadow: -20px 0 50px rgba(11,44,61,.2); + } + .fechar { + position: absolute; top: 14px; right: 16px; z-index: 2; width: 38px; height: 38px; + border-radius: 50%; border: 1px solid var(--line); background: var(--branco); + font-size: 22px; line-height: 1; cursor: pointer; color: var(--navy); + } + .fechar:hover { border-color: var(--orange); color: var(--orange-600); } + .ficha-topo { + background: var(--branco); padding: 26px 28px 20px; border-bottom: 1px solid var(--line); + border-top: 6px solid var(--plano, var(--orange)); + } + .ficha-topo h2 { margin: 10px 0 4px; font-size: 24px; } + .atalhos { display: flex; flex-wrap: wrap; gap: 8px; } + .atalhos .btn { margin-top: 10px; font-size: 14px; padding: 8px 14px; } + .ficha .lista-alertas { margin: 16px 28px 0; } + .grade-ficha { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); padding: 16px 28px 0; } + .grade-ficha section, .bloco-largo { + background: var(--branco); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; + } + .bloco-largo { margin: 14px 28px 0; } + .grade-ficha h3, .bloco-largo h3 { margin: 0 0 10px; font-size: 15px; } + .grade-ficha dl { margin: 0; display: grid; gap: 7px; } + .grade-ficha dl > div { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 14px; } + .grade-ficha dt { color: var(--muted); font-size: 13px; } + .grade-ficha dd { margin: 0; word-break: break-word; } + + .rodape { color: var(--muted); font-size: 12.5px; line-height: 1.6; } + + @media (max-width: 760px) { + .topo { padding: 10px 16px; flex-wrap: wrap; } + .topo .titulo, .topo .apurado { display: none; } + .topo .acao { margin-left: auto; } + .wrap { padding: 14px 16px 32px; } + .cartao { padding: 16px; } + .grade-2 { grid-template-columns: 1fr; } + .barra-linha { grid-template-columns: 120px 1fr 60px; } + .colunas { gap: 6px; } + .linha-do-tempo li { grid-template-columns: 1fr; gap: 2px; } + .grade-ficha, .ficha-topo { padding-left: 16px; padding-right: 16px; } + .bloco-largo, .ficha .lista-alertas { margin-left: 16px; margin-right: 16px; } + .grade-ficha dl > div { grid-template-columns: 110px 1fr; } + } + + painel.html: | + + + + + + + Painel do dono — Athletic Map + + + + + + +
                                  + + Painel do dono + + + +
                                  + +
                                  + + +
                                  +

                                  Entrar

                                  +

                                  + Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

                                  +
                                  + + + +
                                  +

                                  +
                                  + +

                                  Juntando os dados do banco, do Asaas e do login…

                                  + +
                                  + +

                                  Receita

                                  +
                                  +

                                  Clientes

                                  +
                                  + +
                                  +

                                  Precisa da sua atenção

                                  +
                                    +
                                    + + + + +
                                    +
                                    +
                                    + + + + + +
                                    +
                                    + + + + + + + + + + + + + + + +
                                    ClienteContatoPlanoSituaçãoMensalJá pagouPróxima cobrançaUsoCobrança alunos
                                    +
                                    +

                                    Nenhum cliente com esses filtros.

                                    +

                                    Clique numa linha para abrir a ficha completa do cliente.

                                    +
                                    +
                                    + + +
                                    +
                                    +
                                    +

                                    Funil de vendas

                                    +

                                    Do formulário da landing page até o cliente usando o sistema.

                                    +
                                    +
                                    +
                                    +

                                    De onde vêm os clientes

                                    +

                                    Origem marcada no link (utm_source). "direto" é quem chegou sem marcação.

                                    + +
                                    OrigemLeadsPagaramConversão
                                    +
                                    +
                                    +
                                    +

                                    Leads que ainda não compraram

                                    +

                                    Quem deixou os dados e não pagou. Os mais parados merecem uma mensagem.

                                    +
                                    + +
                                    PessoaEscolinhaContatoCidadeAlunosPlano escolhidoLink de pagamentoParado há
                                    +
                                    +

                                    Nenhum lead em aberto.

                                    +
                                    +
                                    + + +
                                    +
                                    +

                                    Recebido por mês

                                    +

                                    Dinheiro que entrou de verdade, pelo Asaas. Laranja é o bruto; azul, o líquido depois das taxas.

                                    +
                                    +
                                    +
                                    +
                                    +

                                    Carteira por plano

                                    + +
                                    PlanoClientesAtivosMRRPeso no MRR
                                    +
                                    +
                                    +

                                    Perfil da carteira

                                    +
                                    +
                                    +
                                    +

                                    + MRR e ARR são estimados pela tabela de preços sobre as assinaturas + ativas; o anual conta como um doze avos por mês. O recebido é o + extrato do Asaas. A diferença entre os dois é o que vale investigar. + Contas do plano de teste não entram no MRR, mas o dinheiro delas entra no recebido. +

                                    +
                                    + + +
                                    +
                                    +

                                    O que aconteceu por último

                                    +

                                    Mudanças de situação das assinaturas e avisos que chegaram do Asaas.

                                    +
                                      +
                                      +
                                      + +
                                      + + + + +
                                      + + + + + + painel.js: | + /* O painel do dono. + * + * ── O TOKEN ────────────────────────────────────────────────────────────────── + * + * Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um + * token de administracao que sobrevive ao fechamento fica numa maquina que + * ninguem lembra que o tem. + * + * Ele NUNCA e escrito na URL. Query string vai para historico do navegador, + * para log de servidor e para o `Referer` de qualquer link clicado a partir + * daqui — tres lugares onde ninguem procura quando o segredo vaza. + * + * ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ───────────────────────────────────── + * + * Nome de escolinha, cidade, cargo: o visitante digita na landing page. Esta + * tela tem o token na sessao; um ` + + + + preparando.js: | + /* =========================================================================== + * P5.8 — a tela "estamos preparando sua conta". + * + * ── POR QUE ESTE SCRIPT NAO E INLINE ───────────────────────────────────────── + * + * A CSP servida pelo nginx diz `script-src 'self'`. Um