/* Painel Nanda — raiz do app: login (Supabase Auth + e-mail/senha), papéis, roteamento, dados. NOTA: o login é por e-mail/senha. O gate real é Supabase Auth + profiles.role sob RLS (ver supabase_schema.sql e docs/setup-supabase.md, passo 4); a porta de entrada pode mudar sem mexer nele. Quando o projeto Supabase NOVO e próprio da Nanda existir (ver README.md), conferir se vale habilitar "Entrar com Google" nele: Authentication → Providers → Google, com um client OAuth do Google Cloud cujo redirect URI aponte pro projeto DA NANDA — nunca reaproveitar o client/redirect URI de outro cliente. */ (function () { const I = window.App.Icons; const U = window.App.UI; const D = window.App.Data; const A = window.App.ScreensA; const B = window.App.ScreensB; const CFG = window.NANDA_CONFIG || {}; const BR = CFG.brand || {}; const { useState, useEffect, useRef, useCallback } = React; const ROUTES = { dashboard: A.Dashboard, pipeline: A.Pipeline, chat: A.Chat, contacts: A.Contacts, training: B.Training, scheduling: B.Scheduling, team: B.Team, help: B.Help, settings: B.Settings, }; const ADMIN_ONLY = { team: true, settings: true }; // UX; a trava real é a RLS por papel // Rotas de VENDAS (exigem ver_vendas). 'help' NÃO é vendas. const VENDAS_ROUTES = { dashboard: true, pipeline: true, chat: true, contacts: true, scheduling: true, training: true, team: true, settings: true }; const routeFromHash = () => (location.hash.replace('#/', '') || 'dashboard').split('?')[0]; class Boundary extends React.Component { constructor(p) { super(p); this.state = { err: null }; } static getDerivedStateFromError(err) { return { err }; } render() { if (this.state.err) return (
Erro ao renderizar o painel
{String(this.state.err && this.state.err.stack || this.state.err)}
); return this.props.children; } } /* ─────────────── LOGIN ─────────────── */ function Login({ onSubmit, busy, error }) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const canSubmit = email.trim() && password && !busy; const submit = () => { if (canSubmit) onSubmit(email.trim(), password); }; const inputCls = 'h-11 w-full rounded-lg border border-line2 bg-surface px-3.5 text-[14px] text-fg placeholder:text-faint outline-none focus:border-brand/60'; return (
{BR.workspace || 'Nanda · Rádio Real FM'}
Painel de atendimento inteligente
Acesso restrito à equipe autorizada.
setEmail(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} type="email" autoComplete="username" placeholder="seu@email.com" className={inputCls} /> setPassword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} type="password" autoComplete="current-password" placeholder="Senha" className={inputCls} />
{error &&
{error}
}
Desenvolvido pela Gerando Impulso IA
); } /* ─────────────── ACESSO NEGADO (logou, mas não está na allowlist) ─────────────── */ function Denied({ email, onLogout }) { return (
Acesso não autorizado
A conta {email ? {email} : 'usada'} não está na lista de acesso do painel da Nanda. Fale com um administrador para ser liberado.
); } function App() { const supa = D.client && D.client(); const [route, setRoute] = useState(routeFromHash()); const [navOpen, setNavOpen] = useState(false); // drawer da sidebar no mobile const [data, setData] = useState(null); const [err, setErr] = useState(null); // auth: 'loading' | 'anon' | 'authed' | 'denied' | 'skip'(DEMO/sem cliente) const [auth, setAuth] = useState(supa ? 'loading' : 'skip'); const [me, setMe] = useState(null); // { email, role } const [perms, setPerms] = useState({ verVendas: true, loaded: false }); // permissões do painel (verVendas default true = compat) const [busy, setBusy] = useState(false); const [authErr, setAuthErr] = useState(null); const mountedRef = useRef(true); // vira false no unmount -> corta loads em 2º plano const roleTokenRef = useRef(0); // invalida loads de papel obsoletos (logout / troca de sessão) useEffect(() => () => { mountedRef.current = false; }, []); // Carrega profiles.role em SEGUNDO PLANO, com retry/backoff. NUNCA trava o login: a sessão já entra // como 'authed' e o papel chega depois, virando isAdmin real (Configurações/Equipe abrem). Só vira // 'denied' quando a query RESPONDE e não há linha (allowlist real). Erro/timeout viram nova tentativa — // o papel do admin NUNCA é perdido de vez por um timeout de 3s (bug visto em outra instalação). const loadRole = useCallback((session) => { if (!supa || !session || !session.user) return; const uid = session.user.id; const email = session.user.email; const token = ++roleTokenRef.current; const delays = [0, 800, 2000, 4000, 8000, 15000, 30000]; // ~1min de tentativas cobre cold start / rede ruim const live = () => mountedRef.current && token === roleTokenRef.current; const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('profile-timeout')), ms))]); const attempt = async (i) => { if (!live()) return; try { const { data: prof, error } = await withTimeout( supa.from('profiles').select('email,role').eq('id', uid).maybeSingle(), 8000); if (!live()) return; if (error) throw error; // erro transitório (rede/RLS) -> retry, sem perder o papel if (prof) { setMe({ id: uid, email: prof.email || email, role: prof.role, loading: false }); setAuth('authed'); return; } setMe({ id: uid, email, role: null, loading: false }); setAuth('denied'); // resposta definitiva SEM linha } catch (e) { if (!live()) return; const next = i + 1; if (next < delays.length) setTimeout(() => { if (live()) attempt(next); }, delays[next]); // esgotou as tentativas: segue 'authed' com papel desconhecido (isAdmin=false), sem travar; // o próximo onAuthStateChange / refresh de token dispara loadRole de novo. } }; attempt(0); }, [supa]); useEffect(() => { const onHash = () => setRoute(routeFromHash()); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); // Bootstrap de autenticação + reação a login/logout useEffect(() => { if (!supa) return; // DEMO / sem cliente -> sem trava de login let alive = true; async function resolve(session) { if (!alive) return; if (!session || !session.user) { roleTokenRef.current++; setMe(null); setAuth('anon'); return; } // Sessão válida: ENTRA JÁ (login rápido, sem esperar o profiles). Preserva o papel que já houver // pra este mesmo usuário (evita piscar), senão marca loading. O papel real vem no loadRole (com retry). setMe((prev) => (prev && prev.id === session.user.id) ? prev : { id: session.user.id, email: session.user.email, role: undefined, loading: true }); setAuth('authed'); loadRole(session); } supa.auth.getSession() .then(({ data }) => resolve(data.session)) .catch(() => { if (alive) setAuth((p) => (p === 'loading' ? 'anon' : p)); }); const { data: sub } = supa.auth.onAuthStateChange((_e, session) => resolve(session)); // Fallback anti-travamento: se a checagem de sessao nao resolver em alguns // segundos (ex.: sessao antiga/expirada que trava no refresh do token), mostra // a tela de login em vez de ficar preso em "Verificando acesso" pra sempre. const guard = setTimeout(() => { if (alive) setAuth((p) => (p === 'loading' ? 'anon' : p)); }, 4500); return () => { alive = false; clearTimeout(guard); if (sub && sub.subscription) sub.subscription.unsubscribe(); }; }, [supa]); // Carrega dados só quando autorizado (ou DEMO/skip). Antes do login não faz fetch. useEffect(() => { if (auth !== 'authed' && auth !== 'skip') return; let alive = true; (async () => { try { // dashboard() sai do Promise.all de propósito (07/10/2026): ele // calcula em cima do MESMO leads/sessions já paginados abaixo, sem // round-trip novo ao Supabase — por isso espera os dois resolverem // antes de rodar (puramente síncrono por dentro, não adiciona // latência perceptível). const [leads, sessions, confirm, health] = await Promise.all([ D.leads(), D.sessions(), D.confirmacoes(), D.health(), ]); const dash = await D.dashboard(leads, sessions); if (alive) setData({ leads, sessions, confirm, dash, health }); } catch (e) { if (alive) setErr(e); } })(); return () => { alive = false; }; }, [auth]); // Permissão de vendas (trava no banco). Carrega em paralelo; enquanto não // resolve, loaded=false. Falha/sem permissão => painel assume verVendas=true // (fail-open aqui é proposital: a RLS real do Supabase já barra o dado; isto // é só UX). useEffect(() => { if (auth !== 'authed' && auth !== 'skip') { setPerms({ verVendas: true, loaded: false }); return; } let alive = true; (async () => { try { const p = await D.perms(); if (alive) setPerms({ verVendas: p.verVendas !== false, loaded: true }); } catch (e) { if (alive) setPerms({ verVendas: true, loaded: true }); } })(); return () => { alive = false; }; }, [auth]); const go = (r) => { location.hash = '#/' + r; setRoute(r); setNavOpen(false); }; const signIn = async (email, password) => { if (!supa) return; setBusy(true); setAuthErr(null); try { const _to = new Promise((_, rej) => setTimeout(() => rej(new Error('A conexão de login não respondeu. Tente novamente.')), 12000)); const { data, error } = await Promise.race([supa.auth.signInWithPassword({ email, password }), _to]); if (error) { setAuthErr(String(error.message || error)); setBusy(false); return; } // sucesso: resolve DIRETO com a sessão retornada — SEM location.reload() (o reload corria com a // gravação da sessão e às vezes voltava pra tela de login). O papel carrega em 2º plano (loadRole). if (data && data.session) { const s = data.session; // Login rápido: entra JÁ. O papel (admin?) carrega em 2º plano com retry — nunca perdido por timeout. setMe({ id: s.user.id, email: s.user.email, role: undefined, loading: true }); setAuth('authed'); loadRole(s); setBusy(false); return; } setBusy(false); } catch (e) { setAuthErr(String(e.message || e)); setBusy(false); } }; const signOut = async () => { try { await supa.auth.signOut(); } catch (e) { /* noop */ } location.hash = ''; location.reload(); }; // ── Gates de acesso ── if (auth === 'loading') return (
Verificando acesso…
); if (auth === 'anon') return ; if (auth === 'denied') return ; const role = me ? me.role : 'admin'; // DEMO/skip => UI completa const roleLoading = !!(me && me.loading); // papel ainda carregando em 2º plano (NÃO é "não-admin") const isAdmin = role === 'admin'; let effRoute = route; let checkingPerm = false; // "Home": único fluxo nesta instância, então é sempre o Dashboard quando há // permissão de vendas; sem ela, cai na Ajuda. const homeRoute = perms.verVendas ? 'dashboard' : 'help'; if (ADMIN_ONLY[effRoute] && !isAdmin) { // Rota admin-only: se o papel ainda carrega, NÃO redireciona — espera o papel chegar (senão o admin // que abre/atualiza direto em #/settings cairia no dashboard). Só redireciona quando o papel resolveu // e não é admin. if (roleLoading) checkingPerm = true; else effRoute = homeRoute; } // Rotas de VENDAS: exigem ver_vendas (trava real = RLS de sessions/leads/confirmacoes). if (VENDAS_ROUTES[effRoute] && !perms.verVendas) { if (!perms.loaded) checkingPerm = true; else effRoute = homeRoute; } const Screen = ROUTES[effRoute] || A.Dashboard; const full = effRoute === 'chat' || effRoute === 'pipeline'; return (
setNavOpen(false)} verVendas={perms.verVendas} />
{/* Barra superior mobile: hambúrguer + marca (some no desktop) */}
{BR.empresa || 'Nanda'}
{D.DEMO && (
Modo demonstração: dados de exemplo no formato do banco (nenhum dado real lido/escrito).
)}
{err ? (
Falha ao carregar dados
{String(err.message || err)}
) : checkingPerm ? (
Verificando permissões…
) : !data ? (
Carregando…
) : ( )}
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render(); })();