/* ============================================================ KeS Metas — WhatsApp (estrutura REAL, pronta para o motor) ------------------------------------------------------------ • Mensagens em tempo real no banco (1 doc por mensagem). • Envio = FILA: o app grava "pendente"; a PONTE (motor Evolution ou API oficial/Coexistência) envia e marca "enviada". • Caixa por usuário: cada um vê a sua; líder vê a equipe; adm/master vê todos. Supervisão = somente leitura. • AUTOMAÇÕES: config em state.config.whatsAuto (boas-vindas / fora de horário). A ponte lê essa config e responde sozinha gravando na mesma fila com origem:"auto". • Sem motor conectado: dá pra testar tudo no DEV com o botão "Receber msg de teste" (só aparece no ambiente de teste). ============================================================ */ function WhatsAppScreen({ state, dispatch, viewer, isOwner }) { const { useState, useEffect, useRef, useMemo } = React; const DB = window.KESDB; // Tenant em foco: empresa (multiempresa) OU equipe — bancos separados. const teamId = tenantIdAtual(state); const EMDEV = (typeof location !== "undefined") && (location.hostname === "localhost" || location.hostname === "127.0.0.1"); // ---- Permissões ---- const ehAdm = isOwner || viewer.papel === "admin"; const ehLider = viewer.cargo === "lider"; const donos = (state.users || []).filter((u) => u.cargo); const caixasVisiveis = (ehAdm || ehLider) ? donos : donos.filter((u) => u.id === viewer.id); const [caixaId, setCaixaId] = useState(() => (caixasVisiveis.some((u) => u.id === viewer.id) ? viewer.id : (caixasVisiveis[0] || {}).id) || null); const caixaDona = donos.find((u) => u.id === caixaId) || null; const minhaCaixa = caixaId === viewer.id; // ---- Dados reais (tempo real) ---- const [msgs, setMsgs] = useState([]); const [caixas, setCaixas] = useState({}); // { userId: {status, canal, numero, qr} } const [erroBanco, setErroBanco] = useState(false); useEffect(() => { if (!DB || !DB.waCaixasListen || !teamId) return; return DB.waCaixasListen(teamId, setCaixas); }, [teamId]); // ---- Atendimento por conversa: humano assumiu? (IA pausa naquela conversa) ---- const [atend, setAtend] = useState({}); // { contato: {assumido, por} } useEffect(() => { if (!DB || !DB.waAtendListen || !teamId) return; return DB.waAtendListen(teamId, setAtend); }, [teamId]); // ---- AVISOS "sem resposta": relógio das conversas + re-render por minuto ---- const [fup, setFup] = useState({}); // { contato: {ultimoTs, ultimaDir, pergunta} } useEffect(() => { if (!DB || !DB.waFupListen || !teamId) return; return DB.waFupListen(teamId, setFup); }, [teamId]); const [, setRelogio] = useState(0); useEffect(() => { const t = setInterval(() => setRelogio((n) => n + 1), 60000); return () => clearInterval(t); }, []); // Baseia-se na ÚLTIMA mensagem REAL da conversa → reseta na hora que respondemos. // Só avisa quando a mensagem PEDE resposta: abertura de conversa (oi, olá, // bom dia…) ou PERGUNTA. "ok", "obrigado" etc. não geram alerta. const ehAbertura = (t) => { const s = String(t || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").trim(); if (!s || s.length > 40) return false; return /(^|\b)(oi+|ola+|opa|hey|hello|alo+|e ?ai|bom dia|boa tarde|boa noite|tudo bem|td bem)(\b|[!,. ]|$)/.test(s); }; const ehPergunta = (t) => { const s = String(t || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").trim(); if (s.indexOf("?") >= 0) return true; return /^(quanto|qual|quais|como|quando|onde|quem|por ?que|pq|sera|pode|podem|poderia|tem|teria|voces|vcs|consigo|da pra|queria saber|gostaria de saber)\b/.test(s); }; const avisoDe = (contato) => { const key = String(contato || "").replace(/\D/g, ""); const cv = conversas.find((x) => String(x.contato || "").replace(/\D/g, "") === key); if (!cv || !cv.msgs.length) return null; const ult = cv.msgs[cv.msgs.length - 1]; if (ult.dir !== "in") return null; // respondemos → sem aviso const pergunta = ehPergunta(ult.texto); if (!pergunta && !ehAbertura(ult.texto)) return null; // não pede resposta → sem alerta const min = Math.floor((Date.now() - Number(ult.ts || 0)) / 60000); if (min < 30) return null; // aviso a partir de 30 minutos return { min: min, pergunta: pergunta }; }; useEffect(() => { setMsgs([]); setErroBanco(false); if (!DB || !DB.waListen || !teamId || !caixaId) return; return DB.waListen(teamId, { caixaId: caixaId }, (lista, err) => { if (err) { setErroBanco(true); return; } setMsgs(lista || []); }); }, [teamId, caixaId]); const caixa = caixas[caixaId] || { status: "desconectado" }; const conectado = caixa.status === "conectado"; // ---- Conversas (agrupadas por contato) ---- const conversas = useMemo(() => { const map = {}; msgs.forEach((m) => { const key = String(m.contato || "").replace(/\D/g, "") || "?"; if (!map[key]) map[key] = { contato: m.contato, nome: m.contatoNome || m.contato, msgs: [], naoLidas: 0 }; map[key].msgs.push(m); if (m.contatoNome) map[key].nome = m.contatoNome; if (m.dir === "in" && !m.lida) map[key].naoLidas++; }); return Object.keys(map).map((k) => map[k]) .sort((a, b) => (b.msgs[b.msgs.length - 1].ts || 0) - (a.msgs[a.msgs.length - 1].ts || 0)); }, [msgs]); const [convKey, setConvKey] = useState(null); const [texto, setTexto] = useState(""); // CRM dentro do WhatsApp: ficha do lead (criar/editar) e tarefa const [leadModal, setLeadModal] = useState(null); // null | prefill (novo) | lead (editar) const [tarefaLead, setTarefaLead] = useState(null); // { leadFixo } // Mensagens ricas: responder (quote), editar, reagir, áudio e arquivos const [replyTo, setReplyTo] = useState(null); // msg sendo respondida const [editando, setEditando] = useState(null); // msg própria sendo editada const [reagindo, setReagindo] = useState(null); // msg alvo do seletor de emoji const [gravando, setGravando] = useState(false); // gravador de áudio ativo? const gravRef = useRef(null); // MediaRecorder + chunks const fileRef = useRef(null); // input de arquivo escondido const MAX_ARQ = 15 * 1024 * 1024; // 15 MB via Storage (limite do WhatsApp) const MAX_B64 = 700 * 1024; // reserva sem Storage: base64 no banco const EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; const [emojiPicker, setEmojiPicker] = useState(false); // seletor de emoji do composer const EMOJIS_TODOS = ["😀","😁","😂","🤣","😊","😍","😘","😎","🤔","😅","😉","🙃","😢","😭","😡","🥳","🤝","👍","👎","👏","🙏","💪","👌","✌️","🤞","❤️","💚","💙","💛","🖤","🔥","⭐","✅","❌","⚠️","🎉","🎯","📞","📅","💰","📦","🚀","☕","😴","🤯","😇","🫡","👀"]; // envia uma mídia: tenta o STORAGE (arquivos grandes); sem Storage → base64 pequeno async function subirEEnviar(blob, nome, docBase) { const up = DB.waUpload ? await DB.waUpload(teamId, blob, nome) : { ok: false }; if (up.ok) { DB.waSend(teamId, Object.assign({ mediaUrl: up.url }, docBase)); return true; } if (blob.size > MAX_B64) { dispatch({ type: "TOAST", msg: "⚠ Armazenamento de mídia indisponível — sem ele, o limite é 700 KB. (Habilite o Storage no Firebase.)" }); return false; } return await new Promise((res) => { const fr = new FileReader(); fr.onload = () => { DB.waSend(teamId, Object.assign({ mediaB64: String(fr.result) }, docBase)); res(true); }; fr.readAsDataURL(blob); }); } const [threadAberta, setThreadAberta] = useState(false); const [qrModal, setQrModal] = useState(false); const [autoModal, setAutoModal] = useState(false); const [agenteModal, setAgenteModal] = useState(false); // Agente de IA (prompt no banco) const [followModal, setFollowModal] = useState(false); // Follow-up automático (régua por fase) const [crmAberto, setCrmAberto] = useState(false); // popover CRM do cabeçalho const [confirmaApagar, setConfirmaApagar] = useState(false); // apagar a conversa (só admin) const [confirmaDesconexao, setConfirmaDesconexao] = useState(false); // desconectar o número (só admin) const fimRef = useRef(null); const conv = conversas.find((c) => String(c.contato || "").replace(/\D/g, "") === convKey) || null; useEffect(() => { setConvKey(null); setThreadAberta(false); }, [caixaId]); useEffect(() => { if (fimRef.current) fimRef.current.scrollIntoView({ block: "end" }); }, [msgs, convKey]); // Marca como lidas ao abrir (só na própria caixa) useEffect(() => { if (!conv || !minhaCaixa || !DB || !DB.waUpdateMsg) return; conv.msgs.filter((m) => m.dir === "in" && !m.lida && m._id) .forEach((m) => DB.waUpdateMsg(teamId, m._id, { lida: true })); }, [convKey, msgs.length]); // Respondeu manualmente → ASSUME a conversa (a IA não atropela o humano). function assumirAoResponder() { const chaveAt = String(conv.contato || "").replace(/\D/g, ""); if (DB.waAtendSet && !(atend[chaveAt] || {}).assumido) { DB.waAtendSet(teamId, chaveAt, { assumido: true, por: viewer.nome, porId: viewer.id, em: new Date().toISOString(), auto: true }); dispatch({ type: "TOAST", msg: "Você respondeu — conversa assumida (a IA pausa aqui). Use 'Devolver p/ IA' quando quiser." }); } } function enviar() { const t = texto.trim(); if (!t || !conv || !minhaCaixa || !DB || !DB.waSend) return; setTexto(""); // EDITANDO uma mensagem própria: atualiza o doc + manda o comando pro motor if (editando) { const alvo = editando; setEditando(null); if (alvo._id && DB.waUpdateMsg) DB.waUpdateMsg(teamId, alvo._id, { texto: t.slice(0, 4000), editada: true }); DB.waSend(teamId, { caixaId: caixaId, contato: conv.contato, contatoNome: conv.nome, dir: "out", tipo: "edicao", editarWaId: alvo.waId || "", novoTexto: t.slice(0, 4000), texto: "(edição)", ts: Date.now(), status: "pendente", origem: "app", de: viewer.id, lida: true }); dispatch({ type: "TOAST", msg: "Mensagem editada." }); return; } assumirAoResponder(); const extra = replyTo ? { quotedWaId: replyTo.waId || "", quotedTexto: String(replyTo.texto || "").slice(0, 200) } : {}; setReplyTo(null); DB.waSend(teamId, Object.assign(extra, { caixaId: caixaId, contato: conv.contato, contatoNome: conv.nome, dir: "out", tipo: "texto", texto: t.slice(0, 4000), ts: Date.now(), status: "pendente", origem: "app", de: viewer.id, lida: true, })); } function onKey(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); enviar(); } } // ---- REAGIR com emoji (o motor manda a reação pro WhatsApp) ---- function reagir(m, emoji) { setReagindo(null); if (!m.waId) { dispatch({ type: "TOAST", msg: "Não dá para reagir a esta mensagem (antiga, sem id)." }); return; } DB.waSend(teamId, { caixaId: caixaId, contato: conv.contato, contatoNome: conv.nome, dir: "out", tipo: "reacao", alvoWaId: m.waId, alvoFromMe: !!m.waFromMe, emoji: emoji, texto: emoji + " (você reagiu)", ts: Date.now(), status: "pendente", origem: "app", de: viewer.id, lida: true }); } // ---- ÁUDIO: grava no navegador e manda pela fila (base64) ---- async function gravarAudio() { if (gravando) { try { gravRef.current && gravRef.current.rec.stop(); } catch (e) {} return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const rec = new MediaRecorder(stream, MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? { mimeType: "audio/webm;codecs=opus" } : undefined); const chunks = []; rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; rec.onstop = async () => { stream.getTracks().forEach((tr) => tr.stop()); setGravando(false); const blob = new Blob(chunks, { type: rec.mimeType || "audio/webm" }); if (blob.size > MAX_ARQ) { dispatch({ type: "TOAST", msg: "⚠ Áudio muito grande." }); return; } assumirAoResponder(); const ok = await subirEEnviar(blob, "audio.webm", { caixaId: caixaId, contato: conv.contato, contatoNome: conv.nome, dir: "out", tipo: "audio", texto: "🎤 Áudio", ts: Date.now(), status: "pendente", origem: "app", de: viewer.id, lida: true }); if (ok) dispatch({ type: "TOAST", msg: "🎤 Áudio na fila de envio." }); }; gravRef.current = { rec: rec }; rec.start(); setGravando(true); // trava de segurança: para sozinho em 60s setTimeout(() => { try { rec.state !== "inactive" && rec.stop(); } catch (e) {} }, 60000); } catch (e) { dispatch({ type: "TOAST", msg: "⚠ Microfone indisponível ou sem permissão." }); } } // ---- ARQUIVO/IMAGEM: sobe pro Storage e manda pela fila (até 15 MB) ---- async function enviarArquivo(ev) { const arq = ev.target.files && ev.target.files[0]; ev.target.value = ""; if (!arq || !conv) return; if (arq.size > MAX_ARQ) { dispatch({ type: "TOAST", msg: "⚠ Arquivo acima de 15 MB (limite do WhatsApp)." }); return; } const ehImg = /^image\//.test(arq.type); assumirAoResponder(); const legenda = texto.trim().slice(0, 500); const ok = await subirEEnviar(arq, arq.name, { caixaId: caixaId, contato: conv.contato, contatoNome: conv.nome, dir: "out", tipo: ehImg ? "imagem" : "documento", mediaNome: arq.name, legenda: legenda, texto: ehImg ? ("📷 Imagem" + (legenda ? ": " + legenda : "")) : ("📎 " + arq.name), ts: Date.now(), status: "pendente", origem: "app", de: viewer.id, lida: true }); if (ok) { setTexto(""); dispatch({ type: "TOAST", msg: (ehImg ? "📷 Imagem" : "📎 Arquivo") + " na fila de envio." }); } } // (o botão "Receber msg de teste" foi removido — o motor real já espelha tudo) // Lead do CRM com o mesmo telefone (vínculo automático) const leadDe = (c) => { const dig = String(c && c.contato || "").replace(/\D/g, ""); if (dig.length < 8) return null; return (state.leads || []).find((l) => ((l.telefone || "") + (l.telefone2 || "") + (l.telefone3 || "")).replace(/\D/g, "").includes(dig.slice(-8))); }; const hora = (ts) => { const d = new Date(ts || 0); const z = (n) => (n < 10 ? "0" : "") + n; return z(d.getHours()) + ":" + z(d.getMinutes()); }; const tick = (m) => m.dir !== "out" ? "" : (m.status === "pendente" ? " 🕓" : m.status === "erro" ? " ⚠" : " ✓✓"); // Automações (config da equipe — a ponte consome) const auto = (state.config && state.config.whatsAuto) || {}; return (

WhatsApp {conectado ? Conectado : AGUARDANDO CONEXÃO} {caixa.canal && {caixa.canal === "cloudapi" ? "🅰️ Oficial" : "🅱️ Não-oficial"}} {auto.ativo && 🤖} {/* Ações compactas (só ícone) — economizam espaço de tela */} {(ehAdm || ehLider) && } {ehAdm && } {(ehAdm || ehLider) && } {/* Conectar: o DONO da caixa, e também admin/líder (podem conectar qualquer caixa da equipe em foco — cada tenant tem instância própria) */} {!conectado && (minhaCaixa || ehAdm || ehLider) && } {/* DESCONECTAR o número (só admin) — o motor faz o logout no Evolution */} {conectado && ehAdm && }

{ehAdm ? "Caixas de WhatsApp da equipe." : ehLider ? "WhatsApp da sua equipe." : "Suas conversas de WhatsApp."}

{caixasVisiveis.length > 1 && ( )}
{erroBanco && (
O banco recusou a leitura do WhatsApp — as regras precisam ser publicadas (fale com o suporte).
)} {!minhaCaixa && caixaDona && (
Caixa de  {caixaDona.nome}  — modo supervisão (somente leitura).
)}
{/* Conversas */} {/* Conversa */}
{!conv ? (
{conectado ? "Escolha uma conversa" : "WhatsApp ainda não conectado"}
{conectado ? "As mensagens chegam em tempo real." : "Use o botão Conectar para vincular o número."}
) : ( <>
{String(conv.nome || "?").split(" ").map((p) => p[0]).slice(0, 2).join("").toUpperCase()}
{conv.nome}
{conv.contato}
{(() => { const lead = leadDe(conv); // CRM vira um botão único no cabeçalho (abre o popover com as ações). if (lead) return (
); // Quem chamou no Whats ainda não é lead → vira lead em 1 clique, // com nome/telefone/responsável já preenchidos (ajusta e salva). const dig = String(conv.contato || "").replace(/\D/g, ""); return setLeadModal({ nome: (conv.nome && conv.nome !== conv.contato) ? conv.nome : "", empresa: "", telefone: (dig ? "+" + dig : String(conv.contato || "")), telefone2: "", telefone3: "", email: "", socios: "", instagram: "", google: "", linkedin: "", porte: "", endereco: "", origem: "WhatsApp", valor: "", responsavelId: (caixaDona && caixaDona.id) || viewer.id, tags: [], observacoes: "", stageId: (state.stages[0] || {}).id, })}>Criar lead; })()} {/* APAGAR CONVERSA (só administradores) — com confirmação */} {ehAdm && ( )} {/* ASSUMIR CONVERSA: humano assume → a IA para de responder AQUI. "Devolver" religa a IA nesta conversa. O motor lê isso do banco. */} {(minhaCaixa || ehAdm || ehLider) && (() => { const chave = String(conv.contato || "").replace(/\D/g, ""); const a = atend[chave] || {}; return a.assumido ? { DB.waAtendSet(teamId, chave, { assumido: false, devolvidaPor: viewer.nome }); dispatch({ type: "TOAST", msg: "Conversa devolvida — a IA volta a responder aqui. 🤖" }); }}> Devolver p/ IA : { DB.waAtendSet(teamId, chave, { assumido: true, por: viewer.nome, porId: viewer.id, em: new Date().toISOString() }); dispatch({ type: "TOAST", msg: "Você assumiu esta conversa — a IA não responde mais aqui." }); }}> Assumir conversa; })()}
{(() => { const chave = String(conv.contato || "").replace(/\D/g, ""); const a = atend[chave] || {}; return a.assumido ? (
🤖 IA pausada nesta conversa — atendimento com  {a.por || "a equipe"}
) : null; })()} {/* POPOVER CRM: funções do funil sem sair do WhatsApp — abre pelo botão "CRM" do cabeçalho. Mover de etapa, ficha, agendar, tarefa. */} {crmAberto && (() => { const lead = leadDe(conv); if (!lead) return null; return (
ETAPA { setLeadModal(lead); setCrmAberto(false); }}>Ficha { dispatch({ type: "AGENDAR_LEAD", lead: lead }); setCrmAberto(false); }}>Agendar reunião { setTarefaLead({ leadFixo: lead }); setCrmAberto(false); }}>Tarefa
); })()}
{conv.msgs.map((m) => { if (m.tipo === "edicao") return null; // comando interno — não é bolha if (m.tipo === "reacao") return (
{m.texto} · {hora(m.ts)}
); const minha = m.dir === "out"; return (
{m.origem === "auto" &&
🤖 resposta automática
} {m.quotedTexto && (
↩ {m.quotedTexto}
)} {m.tipo === "audio" && (m.mediaUrl || m.mediaB64) ?
); })}
{minhaCaixa ? (
{replyTo && (
↩ Respondendo: {String(replyTo.texto || "").slice(0, 90)}
)} {editando && (
✏️ Editando mensagem — altere o texto e envie
)} {emojiPicker && (
{EMOJIS_TODOS.map((e) => )}
)}