// components.jsx — blocos compartilhados do painel Comissa // Logo, NavBar, botões, badges, dashboard mock de vendas, ícones. const { useEffect, useRef, useState, useMemo } = React; // ───────────────────────────────────────────────────────────── // Helpers globais — scroll, toast, drift de valores ao vivo // ───────────────────────────────────────────────────────────── function scrollToId(id) { const el = document.getElementById(id); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function toast(msg) { let host = document.getElementById('cd-toast-host'); if (!host) { host = document.createElement('div'); host.id = 'cd-toast-host'; Object.assign(host.style, { position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)', zIndex: '9999', display: 'flex', flexDirection: 'column', gap: '8px', pointerEvents: 'none', }); document.body.appendChild(host); } const t = document.createElement('div'); t.textContent = msg; Object.assign(t.style, { // Herança do tema escuro: aqui estava o valor de `--bg` com texto branco, // ou seja, um toast invisível. O toast é uma pílula ESCURA sobre a página // clara — fundo `--ink`, tinta `--on-ink`. background: 'var(--ink)', color: 'var(--on-ink)', padding: '10px 18px', borderRadius: '999px', fontSize: '13.5px', fontWeight: '500', letterSpacing: '-0.005em', boxShadow: '0 14px 30px -10px rgba(0,0,0,.4)', opacity: '0', transform: 'translateY(8px)', transition: 'opacity .25s ease, transform .25s ease', }); host.appendChild(t); requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'translateY(0)'; }); setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateY(8px)'; setTimeout(() => t.remove(), 300); }, 2200); } // Hook: drifta um valor numérico ao longo do tempo (sobe mais do que desce) function useDrift({ start, min, max, stepMin = 0.01, stepMax = 0.05, upBias = 0.72, intervalMs = 1800 }) { const [v, setV] = useState(start); useEffect(() => { const id = setInterval(() => { setV((prev) => { const dir = Math.random() < upBias ? 1 : -1; const mag = stepMin + Math.random() * (stepMax - stepMin); let next = prev + dir * mag; if (next > max) next = max - (next - max); if (next < min) next = min + (min - next); return next; }); }, intervalMs); return () => clearInterval(id); }, [min, max, stepMin, stepMax, upBias, intervalMs]); return v; } // Hook: interpola suavemente entre o valor anterior e o novo (count-up) function useTween(target, ms = 800) { const [v, setV] = useState(target); const fromRef = useRef(target); useEffect(() => { const startedAt = performance.now(); const from = fromRef.current; const dest = target; let raf; const step = (now) => { const t = Math.min(1, (now - startedAt) / ms); const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic const value = from + (dest - from) * eased; setV(value); fromRef.current = value; if (t < 1) raf = requestAnimationFrame(step); else fromRef.current = dest; }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [target, ms]); return v; } // Componente: renderiza um número que conta suavemente até `value` function TweenedNumber({ value, ms = 900, format }) { const v = useTween(value, ms); const text = format ? format(v) : v.toFixed(0); return {text}; } if (typeof window !== 'undefined') { window.scrollToId = scrollToId; window.toast = toast; window.useDrift = useDrift; window.useTween = useTween; window.TweenedNumber = TweenedNumber; } // ───────────────────────────────────────────────────────────── // Marca — pulso/onda (estilo "ritmo de vendas") // ───────────────────────────────────────────────────────────── function CadenceMark({ size = 22 }) { return ( ); } function Wordmark({ size = 18 }) { return ( Comissa ); } // ───────────────────────────────────────────────────────────── // Ícones inline (stroke 1.6, 18×18) // ───────────────────────────────────────────────────────────── const Icon = ({ d, size = 18, stroke = 1.6, fill = 'none' }) => ( ); const IconArrow = (p) => ; const IconPlay = (p) => } />; const IconCheck = (p) => ; const IconCmd = (p) => ; const IconBolt = (p) => ; const IconGrid = (p) => ; const IconUsers = (p) => ; const IconCircle = (p) => } />; const IconDot = (p) => } />; const IconGithub = (p) => ( ); const IconLayers = (p) => ; const IconSearch = (p) => } />; const IconMic = (p) => ; const IconScreen = (p) => ; const IconDoc = (p) => ; const IconRoute = (p) => } />; const IconClock = (p) => } />; const IconStar = (p) => ; const IconMoney = (p) => } />; const IconTarget = (p) => } />; const IconWhats = (p) => ; const IconPix = (p) => ; // ───────────────────────────────────────────────────────────── // Logos de marcas (integrações) // SVGs estilizados com as cores oficiais — usáveis em qualquer tamanho. // ───────────────────────────────────────────────────────────── const BRAND_LOGOS = { google: ({ size = 24 }) => ( ), whatsapp: ({ size = 24 }) => ( ), pix: ({ size = 24 }) => ( {/* Pinwheel/diamond do Pix */} ), mercadopago: ({ size = 24 }) => ( {/* Aperto de mão estilizado em curva amarela */} ), hotmart: ({ size = 24 }) => ( {/* Chama estilizada */} ), kiwify: ({ size = 24 }) => ( {/* "K" estilizado nas cores Kiwify */} ), asaas: ({ size = 24 }) => ( {/* Setas/A estilizado */} ), }; function BrandLogo({ brand, size = 24 }) { const Comp = BRAND_LOGOS[brand]; if (!Comp) return null; return ; } // ───────────────────────────────────────────────────────────── // AnimatedCheck — motion port de Uiverse.io (mrhyddenn) // CSS associado vive em index.html (.check / .cbx). // Uso: // ou com rótulo: // ───────────────────────────────────────────────────────────── let _checkSeq = 0; function AnimatedCheck({ checked, onChange, id, label, gap = 10 }) { const idRef = useRef(id); if (!idRef.current) idRef.current = 'cbx-' + (++_checkSeq); const uid = idRef.current; const inner = ( onChange && onChange(e.target.checked)} style={{ display: 'none' }} /> ); if (label) { return ( {inner} ); } return inner; } // ───────────────────────────────────────────────────────────── // Botões // ───────────────────────────────────────────────────────────── // ⚠️ O bug que este componente tinha: `background: var(--ink)` com // `color: '#fff'` fixo. Num site ESCURO, `--ink` é a cor do TEXTO claro // (#0D1117) — então o botão virava uma pílula quase branca com letra branca // por cima. Os quatro "Testar grátis" da página eram ilegíveis. // // A tinta do botão agora acompanha o fundo dele: sobre `--ink` (claro) o texto // é `--on-ink` (quase preto); sobre indigo continua branco. function PrimaryButton({ children, size = 'md', tone = 'ink', icon, className, ...p }) { const px = size === 'lg' ? 22 : 16, py = size === 'lg' ? 13 : 9; const fs = size === 'lg' ? 15 : 13.5; const isIndigo = tone === 'indigo'; const bg = isIndigo ? 'var(--indigo)' : 'var(--ink)'; const fg = isIndigo ? '#fff' : 'var(--on-ink)'; const cls = `cd-btn cd-btn-primary-${isIndigo ? 'indigo' : 'ink'}` + (className ? ' ' + className : ''); return ( ); } function GhostButton({ children, size = 'md', className, ...p }) { const px = size === 'lg' ? 20 : 14, py = size === 'lg' ? 12 : 8; const fs = size === 'lg' ? 15 : 13.5; const cls = 'cd-btn cd-btn-ghost' + (className ? ' ' + className : ''); return ( ); } // ───────────────────────────────────────────────────────────── // Eyebrow / badge // ───────────────────────────────────────────────────────────── function Eyebrow({ children, tone = 'default' }) { const bg = tone === 'indigo' ? 'var(--indigo-soft)' : 'rgba(13,17,23,0.06)'; // `--indigo-2` é o azul claro de preenchimento: a 11px sobre a pílula clara // ele dava 2,3:1. Rótulo é texto, então usa a variante de texto. const fg = tone === 'indigo' ? 'var(--indigo-text)' : 'var(--ink-2)'; return ( {children} ); } // ───────────────────────────────────────────────────────────── // NavBar // ───────────────────────────────────────────────────────────── function NavBar({ width = 1280 }) { const links = [ { l: 'Como funciona', t: 'jornada' }, { l: 'Produto', t: 'demo' }, { l: 'Interfaces', t: 'interfaces' }, // Era "App" → #mobile-app. A seção do app nativo saiu (ainda não há o que // lançar); no lugar entrou o pós-venda, que já existe no produto. { l: 'Pós-venda', t: 'pos-venda' }, // `features` era o id da FeatureGrid, que saiu da página. A seção que // responde "para quem vende" hoje é a dos três perfis. Com o id antigo o // item da navegação não levava a lugar nenhum. { l: 'Para quem vende', t: 'perfis', opcional: true }, { l: 'Clientes', t: 'quotes', opcional: true }, { l: 'Preços', t: 'pricing' }, { l: 'Perguntas', t: 'faq' }, ]; // Oito itens pedem ~692px de pill; abaixo de 1180 de janela sobra menos que // isso e o `overflow: hidden` cortava "Perguntas" pela metade. Recortar texto // é o pior dos dois: melhor sair do menu (as duas seções abaixo continuam no // rodapé) e o que fica na barra ficar legível inteiro. const visiveis = width < 1180 ? links.filter((x) => !x.opcional) : links; return ( ); } // ───────────────────────────────────────────────────────────── // Blobs animados (light-mode) // ───────────────────────────────────────────────────────────── function GradientField({ variant = 'centered' }) { const css = ` @keyframes cd-drift-a { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(40px,-20px) scale(1.08)} } @keyframes cd-drift-b { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(-30px,30px) scale(1.04)} } @keyframes cd-drift-c { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(20px,40px) scale(1.1)} } `; // Tema claro: glows do acento azul difusos atrás do conteúdo. const blobs = variant === 'centered' ? [ { c: '#2563EB', x: '50%', y: '20%', s: 820, a: .55, anim: 'cd-drift-a 18s ease-in-out infinite' }, { c: '#1D4ED8', x: '70%', y: '34%', s: 620, a: .42, anim: 'cd-drift-b 22s ease-in-out infinite' }, { c: '#7DD3FC', x: '28%', y: '48%', s: 460, a: .3, anim: 'cd-drift-c 26s ease-in-out infinite' }, ] : variant === 'left' ? [ { c: '#3B82F6', x: '24%', y: '26%', s: 860, a: .5, anim: 'cd-drift-a 18s ease-in-out infinite' }, { c: '#1D4ED8', x: '76%', y: '58%', s: 620, a: .42, anim: 'cd-drift-b 22s ease-in-out infinite' }, { c: '#7DD3FC', x: '58%', y: '78%', s: 420, a: .26, anim: 'cd-drift-c 26s ease-in-out infinite' }, ] : [ { c: '#2563EB', x: '12%', y: '12%', s: 560, a: .45, anim: 'cd-drift-a 18s ease-in-out infinite' }, { c: '#38BDF8', x: '85%', y: '20%', s: 460, a: .3, anim: 'cd-drift-b 22s ease-in-out infinite' }, { c: '#1D4ED8', x: '50%', y: '86%', s: 560, a: .32, anim: 'cd-drift-c 26s ease-in-out infinite' }, ]; return (
{blobs.map((b, i) => (
))} {/* Fecho: uma queda suave pro fundo da página, pra o hero terminar em vez de ser cortado. Antes havia aqui uma vinheta `rgba(ink,0.55)` e um grão em `screen`, os dois desenhados pro hero ESCURO. Sobre o claro a vinheta virava uma mancha cinza que engolia a linha de garantia e os rótulos das métricas — o texto não estava com pouco contraste, estava debaixo de um véu. */}
); } function ParticleField({ count = 24, color = '#2563EB' }) { const dots = useMemo(() => Array.from({ length: count }, (_, i) => ({ x: Math.random() * 100, y: Math.random() * 100, s: 2 + Math.random() * 3, d: 6 + Math.random() * 10, delay: Math.random() * -10, })), [count]); return (
{dots.map((d, i) => (
))}
); } // ───────────────────────────────────────────────────────────── // Dados de exemplo — vendas de várias categorias // ───────────────────────────────────────────────────────────── const VENDAS_NEGOCIANDO = [ { id: 'VND-2417', t: 'Curso Trading Pro · Ana Souza', tag: 'curso', who: 'JS', valor: 1997, comissao: 599, spot: 'row-trading' }, { id: 'VND-2415', t: 'Rolex Submariner · Pedro Marques', tag: 'relogio', who: 'MS', valor: 78000, comissao: 5460, spot: 'row-rolex' }, { id: 'VND-2412', t: 'Honda Civic 2024 · Loja Vista Verde', tag: 'carro', who: 'CO', valor: 142000, comissao: 7100, spot: 'row-civic' }, ]; const VENDAS_FECHANDO = [ { id: 'VND-2402', t: 'Apto 2Q Pinheiros · Família Lima', tag: 'imovel', who: 'FC', valor: 580000, comissao: 17400, spot: 'row-apto' }, { id: 'VND-2398', t: 'iPhone 16 Pro · Mariana R.', tag: 'eletro', who: 'JS', valor: 9499, comissao: 475, spot: 'row-iphone' }, ]; const VENDAS_PAGAS = [ { id: 'VND-2381', t: 'Curso Inglês Fluente · 24 alunos', tag: 'curso', who: 'AS', valor: 47928, comissao: 9586, spot: 'row-curso' }, ]; const TAG_COLORS = { curso: { bg: '#E0F2FE', fg: '#075985' }, relogio: { bg: '#FFEDE4', fg: '#C2371F' }, carro: { bg: '#FEF3C7', fg: '#92400E' }, imovel: { bg: '#DCFCE7', fg: '#166534' }, eletro: { bg: '#CFFAFE', fg: '#155E75' }, joia: { bg: '#FCE7F3', fg: '#E11D48' }, }; // Formatadores PT-BR function brl(n, opts = {}) { const compact = opts.compact; if (compact && n >= 1000) { if (n >= 1000000) return 'R$ ' + (n / 1000000).toFixed(1).replace('.', ',') + 'M'; if (n >= 1000) return 'R$ ' + (n / 1000).toFixed(1).replace('.', ',') + 'k'; } return 'R$ ' + n.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); } function Avatar({ initials, hue = 220, size = 22 }) { return ( {initials} ); } function VendaRow({ v, accent, compact }) { const tag = TAG_COLORS[v.tag] || TAG_COLORS.curso; return (
{v.id} {v.t} {v.tag} {brl(v.valor, { compact: true })} +{brl(v.comissao, { compact: true })}
); } const CURSOR_SPOTS_MARINA = [ 'kpi-vendas', 'filter-status', 'row-rolex', 'kpi-comissao', 'row-apto', 'toolbar-title', ]; const CURSOR_SPOTS_THIAGO = [ 'sidebar-metas', 'kpi-comissao', 'row-curso', 'filter-produto', 'row-iphone', 'sidebar-vendas', ]; // `chrome`: barra de título própria. Sai quando o painel já está dentro de // uma moldura (a JanelaApp da seção de interfaces) — senão empilha duas. // `overlay`: camada que entra DENTRO da moldura, herdando o tilt do painel. // Existe pra notificação de venda poder viver sobre a tela do app em vez de // flutuar ao lado dela — colada, e inclinada junto, senão volta a ler como // dois elementos sem relação. function DashboardMock({ width = 980, tilt = true, showCursors = false, live = false, chrome = true, overlay = null }) { const dashRef = useRef(null); // KPIs do topo (cabeçalho do painel) const baseVendas = [...VENDAS_NEGOCIANDO, ...VENDAS_FECHANDO, ...VENDAS_PAGAS] .reduce((s, v) => s + v.valor, 0); const baseComissao = [...VENDAS_NEGOCIANDO, ...VENDAS_FECHANDO, ...VENDAS_PAGAS] .reduce((s, v) => s + v.comissao, 0); // Modo ao vivo: somar ticks contínuos const [bump, setBump] = useState(0); useEffect(() => { if (!live) return; const id = setInterval(() => setBump((b) => b + 1), 2200); return () => clearInterval(id); }, [live]); const liveVendas = live ? baseVendas + bump * 4180 : baseVendas; const liveComissao = live ? baseComissao + bump * 268 : baseComissao; const liveHoje = live ? 2840 + bump * 92 : 2840; const liveTicket = Math.round(liveVendas / 6); const liveConvNum = live ? 34 + Math.sin(bump / 4) * 1.4 : 34; return (
{overlay}
{/* chrome */} {chrome && (
{['#E05252', '#D97706', '#059669'].map((c, i) => ( ))} comissa.app · minhas vendas ⌘K
)}
{/* sidebar */} {/* main */}
{/* KPI bar */}
{[ { l: 'Vendas no mês', v: liveVendas, fmt: (n) => brl(n, { compact: true }), d: live ? '+18% · ao vivo' : '+18%', up: true, pulse: live, spot: 'kpi-vendas', ms: 1100 }, { l: 'Comissão acumulada', v: liveComissao, fmt: (n) => brl(n, { compact: true }), d: `+${brl(liveHoje, { compact: true })} hoje`, up: true, pulse: live, spot: 'kpi-comissao', ms: 1100 }, { l: 'Ticket médio', v: liveTicket, fmt: (n) => brl(n, { compact: true }), d: '+R$ 380', up: true, spot: 'kpi-ticket', ms: 1100 }, { l: 'Conversão', v: liveConvNum, fmt: (n) => Math.round(n) + '%', d: '−2pp', up: false, spot: 'kpi-conversao', ms: 900 }, ].map((k, i) => { const TN = window.TweenedNumber; return (
{k.l} {k.pulse && ( )}
{k.up ? '↑ ' : '↓ '}{k.d}
); })}
{/* toolbar */}
Todas as vendas
12 abertas · 8 pagas
{[ { l: 'Status', spot: 'filter-status' }, { l: 'Produto', spot: 'filter-produto' }, { l: 'Vendedor', spot: 'filter-vendedor' }, { l: '+ Filtro' }, ].map((f, i) => ( {f.l} ))}
{/* Alvo do roteiro do cursor no hero (data-cdspot). Também é o botão que o painel de verdade tem: sem ele a barra mostrava filtros e nenhuma ação, e o cursor não tinha onde clicar pra a venda entrar. */} + Nova venda
{/* colunas por status */} {[ { label: 'A negociar', accent: '#94A3B4', items: VENDAS_NEGOCIANDO }, { label: 'Em fechamento', accent: '#F59E0B', items: VENDAS_FECHANDO }, { label: 'Pagas', accent: '#059669', items: VENDAS_PAGAS }, ].map((g) => (
{g.label} {g.items.length}
{g.items.map((v) => )}
))}
{/* cursores flutuantes */} {showCursors && ( )}
); } function FloatingCursor({ name, hue = 220, spots, anchorRef, delay = 0, top, left }) { // Modo `spots`: navega entre elementos com data-cdspot dentro de anchorRef. // Modo estático (top/left) usado como fallback. const [pos, setPos] = useState({ top: top || '40%', left: left || '40%' }); const [visible, setVisible] = useState(!!(top || left)); const [clicking, setClicking] = useState(false); useEffect(() => { if (!spots || !spots.length) return; let stopped = false; let idx = -1; const timers = new Set(); const after = (ms, fn) => { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; }; const poke = (el) => { if (!el) return; el.classList.remove('cd-poked'); void el.offsetWidth; el.classList.add('cd-poked'); after(950, () => el.classList.remove('cd-poked')); }; const moveTo = () => { const root = anchorRef && anchorRef.current; if (!root) return null; const el = root.querySelector(`[data-cdspot="${spots[idx]}"]`); if (!el) return null; const wr = root.getBoundingClientRect(); const r = el.getBoundingClientRect(); const cx = r.left - wr.left + Math.min(r.width * 0.45, 70); const cy = r.top - wr.top + r.height / 2 - 2; setPos({ top: cy + 'px', left: cx + 'px' }); setVisible(true); return el; }; const cycle = () => { if (stopped) return; idx = (idx + 1) % spots.length; const el = moveTo(); // pausa pra "chegar", depois clica after(1500, () => { if (stopped) return; setClicking(true); poke(el); after(420, () => { if (!stopped) setClicking(false); }); }); after(2700 + Math.random() * 600, cycle); }; after(700 + delay, cycle); // Re-medir se a janela mudar de tamanho const onResize = () => { if (idx >= 0) moveTo(); }; window.addEventListener('resize', onResize); return () => { stopped = true; timers.forEach(clearTimeout); window.removeEventListener('resize', onResize); }; }, [spots, anchorRef, delay]); return (
{clicking && ( )} {name}
); } // Logos de clientes (marcas fictícias com cara brasileira) const CUSTOMERS = [ { n: 'Velocittà', font: 'serif', weight: 700, letter: '-0.03em', icon: '◆' }, { n: 'Tempora', font: 'serif', weight: 500, letter: '0.06em', icon: null, smallcaps: true }, { n: 'Mestria', font: 'mono', weight: 500, letter: '0.02em', icon: '◐' }, { n: 'Habita+', font: 'sans', weight: 800, letter: '-0.04em', icon: null }, { n: 'Lume Joias', font: 'sans', weight: 700, letter: '-0.03em', icon: '~' }, { n: 'Comércio', font: 'sans', weight: 600, letter: '-0.01em', icon: null }, ]; // ───────────────────────────────────────────────────────────── // Modais: signup / login / contato (com OAuth Google/Facebook) // ───────────────────────────────────────────────────────────── function ModalShell({ title, eyebrow, onClose, children, maxWidth = 440 }) { useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; }; }, [onClose]); return (
e.stopPropagation()} role="dialog" aria-modal="true" style={{ background: 'var(--surface)', borderRadius: 22, width: '100%', maxWidth, padding: '32px 32px 28px', boxShadow: '0 30px 80px -20px rgba(0,0,0,.6), 0 0 0 0.5px rgba(255,255,255,0.08)', fontFamily: 'var(--font)', position: 'relative', boxSizing: 'border-box', animation: 'cd-modal-pop .508s cubic-bezier(.2,.8,.2,1)', }}>
Comissa
{eyebrow && (
{eyebrow}
)}

{title}

{children}
); } function SocialButton({ provider, onClick }) { const cfg = { google: { label: 'Continuar com Google', icon: ( ), }, facebook: { label: 'Continuar com Facebook', icon: ( ), }, }[provider]; return ( ); } function FormField({ label, type = 'text', value, onChange, placeholder, autoFocus, autoComplete }) { return ( ); } function Divider({ children }) { return (
{children}
); } function SubmitButton({ children, onClick, busy }) { return ( ); } // ───────────────────────────────────────────────────────────── // OAuth real: Google Identity Services + Facebook JS SDK // Os IDs vêm de window.AUTH_CONFIG (veja auth-config.js). // ───────────────────────────────────────────────────────────── function loadScriptOnce(src, id) { return new Promise((resolve, reject) => { if (document.getElementById(id)) return resolve(); const s = document.createElement('script'); s.src = src; s.id = id; s.async = true; s.defer = true; s.onload = () => resolve(); s.onerror = () => reject(new Error('SCRIPT_LOAD:' + src)); document.head.appendChild(s); }); } let _googleClient = null; async function googleSignIn() { const cfg = (window.AUTH_CONFIG || {}); if (!cfg.googleClientId) throw new Error('CONFIG_MISSING:google'); await loadScriptOnce('https://accounts.google.com/gsi/client', 'gsi-client'); return new Promise((resolve, reject) => { try { if (!_googleClient) { _googleClient = window.google.accounts.oauth2.initTokenClient({ client_id: cfg.googleClientId, scope: 'openid email profile', callback: () => {}, }); } _googleClient.callback = async (resp) => { if (resp.error) return reject(new Error(resp.error_description || resp.error)); try { const r = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { headers: { Authorization: 'Bearer ' + resp.access_token }, }); if (!r.ok) throw new Error('USERINFO_HTTP:' + r.status); const me = await r.json(); resolve({ provider: 'google', email: me.email, name: me.name, picture: me.picture, accessToken: resp.access_token, raw: me }); } catch (e) { reject(e); } }; _googleClient.requestAccessToken({ prompt: 'select_account' }); } catch (e) { reject(e); } }); } let _fbInited = false; async function facebookSignIn() { const cfg = (window.AUTH_CONFIG || {}); if (!cfg.facebookAppId) throw new Error('CONFIG_MISSING:facebook'); await loadScriptOnce('https://connect.facebook.net/en_US/sdk.js', 'facebook-jssdk'); if (!_fbInited) { window.FB.init({ appId: cfg.facebookAppId, version: 'v18.0', cookie: true, xfbml: false }); _fbInited = true; } return new Promise((resolve, reject) => { window.FB.login((resp) => { if (!resp || resp.status !== 'connected') return reject(new Error('CANCELLED')); window.FB.api('/me', { fields: 'name,email,picture' }, (me) => { if (!me || me.error) return reject(new Error(me?.error?.message || 'FB_API')); resolve({ provider: 'facebook', email: me.email, name: me.name, picture: me.picture && me.picture.data && me.picture.data.url, accessToken: resp.authResponse && resp.authResponse.accessToken, raw: me, }); }); }, { scope: 'public_profile,email' }); }); } // Helper UI: mensagem quando faltam credenciais function ConfigMissingBanner({ provider }) { const isGoogle = provider === 'google'; return (
Falta configurar {isGoogle ? 'o Google Client ID' : 'o Facebook App ID'}
Pra abrir a tela real de login {isGoogle ? 'do Google' : 'do Facebook'} você precisa de uma credencial OAuth. Edite auth-config.js{' '} e cole {isGoogle ? 'o Client ID em ' : 'o App ID em '} {isGoogle ? 'googleClientId' : 'facebookAppId'}.
{isGoogle ? 'Pegue em console.cloud.google.com → Credentials. Inclua http://localhost:8766 nas origens autorizadas.' : 'Pegue em developers.facebook.com/apps. Configure http://localhost:8766 como Site URL.'}
); } async function runAuth(provider, ctx, onMissing, onSuccess, onError) { try { let user; if (provider === 'google') { user = await googleSignIn(); } else if (provider === 'facebook') { user = await facebookSignIn(); } else { // email/password — sem backend, simula o roundtrip await new Promise((r) => setTimeout(r, 700)); user = { provider: 'email', email: ctx.email }; } onSuccess(user); } catch (err) { const msg = String(err && err.message || err); if (msg.startsWith('CONFIG_MISSING:')) { // Sem credencial OAuth cadastrada. Mostrar "falta configurar" pro // visitante é vazar obra pra quem só queria criar a conta — então o // padrão é simular o roundtrip e seguir. Quem está configurando de // verdade liga `avisarFaltaDeConfig` no auth-config.js e vê o aviso. const qual = msg.split(':')[1]; if (window.AUTH_CONFIG && window.AUTH_CONFIG.avisarFaltaDeConfig) { onMissing(qual); } else { await new Promise((r) => setTimeout(r, 950)); onSuccess({ provider: qual, email: (ctx && ctx.email) || (qual === 'google' ? 'voce@gmail.com' : 'voce@facebook.com'), name: null, simulado: true, }); } } else if (msg === 'CANCELLED' || msg.includes('popup_closed') || msg.includes('access_denied')) { // usuário cancelou — silencioso } else { window.toast('Não conseguimos completar o login. Tente de novo.'); console.error('[auth]', err); } onError && onError(err); } } function SignupContent({ onClose, switchTo }) { const [email, setEmail] = useState(''); const [pwd, setPwd] = useState(''); const [busy, setBusy] = useState(false); const [done, setDone] = useState(false); const [missing, setMissing] = useState(null); const submit = (provider) => { if (busy) return; if (provider === 'email' && (!email.includes('@') || pwd.length < 4)) { window.toast('Preencha email e senha (4+ caracteres).'); return; } setMissing(null); setBusy(true); runAuth( provider, { email }, (which) => { setBusy(false); setMissing(which); }, (user) => { setBusy(false); setDone(true); window.toast(`Conta criada${user.name ? ' como ' + user.name : provider === 'email' ? ' com ' + email : ' com ' + provider}!`); setTimeout(onClose, 1200); }, () => setBusy(false), ); }; if (done) return (
Tudo certo!
Sua conta Comissa foi criada.
); return ( submit('google')} />
submit('facebook')} /> {missing && } ou com email
submit('email')} busy={busy}>Criar conta grátis
Ao criar a conta você aceita os Termos e a Política de Privacidade.
14 dias grátis · Sem cartão · Cancela em um clique.
); } function LoginContent({ onClose, switchTo }) { const [email, setEmail] = useState(''); const [pwd, setPwd] = useState(''); const [busy, setBusy] = useState(false); const [missing, setMissing] = useState(null); const submit = (provider) => { if (busy) return; if (provider === 'email' && (!email.includes('@') || pwd.length < 4)) { window.toast('Email ou senha inválidos.'); return; } setMissing(null); setBusy(true); runAuth( provider, { email }, (which) => { setBusy(false); setMissing(which); }, (user) => { setBusy(false); window.toast(user.name ? `Bem-vindo, ${user.name}!` : 'Bem-vindo de volta!'); onClose(); }, () => setBusy(false), ); }; return ( submit('google')} />
submit('facebook')} /> {missing && } ou
submit('email')} busy={busy}>Entrar
); } function ContactContent({ onClose }) { const [nome, setNome] = useState(''); const [email, setEmail] = useState(''); const [msg, setMsg] = useState(''); const [busy, setBusy] = useState(false); const [done, setDone] = useState(false); const submit = () => { if (busy) return; if (!nome.trim() || !email.includes('@')) { window.toast('Preencha nome e email válidos.'); return; } setBusy(true); setTimeout(() => { setBusy(false); setDone(true); setTimeout(onClose, 1500); }, 900); }; if (done) return (
Recebemos!
Respondemos em até 30 minutos no seu email.
); return (