diff --git a/components/crm/GerenciadorInstancias.tsx b/components/crm/GerenciadorInstancias.tsx index 790f907..cbce2ef 100644 --- a/components/crm/GerenciadorInstancias.tsx +++ b/components/crm/GerenciadorInstancias.tsx @@ -1,12 +1,15 @@ "use client" -import { useState, useTransition } from "react" +import { useEffect, useRef, useState, useTransition } from "react" import { useRouter } from "next/navigation" import type { EmpresaMeta } from "@/lib/data" import type { CrmInstanciaRow } from "@/lib/crm-instancias-actions" import { criarInstanciaAction, gerarQrAction, + gerarCodigoPareamentoAction, + reiniciarInstanciaAction, + buscarStatusInstanciaAction, desativarInstanciaAction, reativarInstanciaAction, atualizarCorInstanciaAction, @@ -280,19 +283,121 @@ function LinhaInstancia({ }) { const [pending, startTransition] = useTransition() const [erro, setErro] = useState(null) + const [aviso, setAviso] = useState(null) const [corAberta, setCorAberta] = useState(false) const [confirmarExcluir, setConfirmarExcluir] = useState(false) + const [qrLocal, setQrLocal] = useState(null) + const [pairingLocal, setPairingLocal] = useState(null) + const [mostrarCodigo, setMostrarCodigo] = useState(false) + const [numeroCodigo, setNumeroCodigo] = useState(instancia.numero_e164 ?? "") + const pollRef = useRef | null>(null) + const pollTimeoutRef = useRef | null>(null) const router = useRouter() const empresaNome = empresas.find((e) => e.slug === instancia.empresa_slug)?.nome ?? instancia.empresa_slug const nomeExibido = instancia.display_nome || instancia.instance_name + const qrExibido = qrLocal ?? instancia.ultimo_qr + const pairingExibido = pairingLocal ?? instancia.ultimo_pairing_code + + function pararPolling() { + if (pollRef.current) clearInterval(pollRef.current) + if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current) + pollRef.current = null + pollTimeoutRef.current = null + } + + // Enquanto aguarda o usuário escanear/digitar o código, consulta o status + // a cada poucos segundos — não dá pra confiar só no Realtime do Supabase + // (silenciosamente desligado se faltar env var) nem num único + // router.refresh() logo após o clique (o QR muitas vezes só existe alguns + // instantes depois, quando o webhook da Evolution confirma). + function iniciarPolling() { + pararPolling() + pollRef.current = setInterval(async () => { + const status = await buscarStatusInstanciaAction(instancia.id) + if (!status) return + if (status.status_conexao === "conectado") { + pararPolling() + setQrLocal(null) + setPairingLocal(null) + setAviso(null) + router.refresh() + return + } + setQrLocal(status.ultimo_qr) + setPairingLocal(status.ultimo_pairing_code) + }, 2500) + pollTimeoutRef.current = setTimeout(pararPolling, 45_000) + } + + useEffect(() => () => pararPolling(), []) + + // Segurança extra: se a prop virar "conectado" por qualquer outro caminho + // (Realtime, ou o refresh de uma ação irmã), limpa o QR/código locais e + // para de sondar — sem isso a imagem antiga podia ficar presa na tela. + useEffect(() => { + if (instancia.status_conexao === "conectado") { + pararPolling() + setQrLocal(null) + setPairingLocal(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instancia.status_conexao]) + + function aplicarResultadoQr(r: { + ok: boolean + erro?: string + qrBase64?: string + pairingCode?: string + }) { + if (!r.ok) { + setErro(r.erro ?? "Erro") + setAviso(null) + return + } + setErro(null) + setAviso(r.erro ?? null) + if (r.qrBase64) { + setQrLocal(r.qrBase64) + setPairingLocal(null) + } + if (r.pairingCode) { + setPairingLocal(r.pairingCode) + setQrLocal(null) + } + if (r.qrBase64 || r.pairingCode) iniciarPolling() + } async function gerarQr() { const fd = new FormData() fd.set("id", instancia.id) const r = await gerarQrAction(fd) - setErro(r.ok ? null : r.erro ?? "Erro ao gerar QR") + aplicarResultadoQr(r) + router.refresh() + } + + async function gerarCodigo() { + const digitos = numeroCodigo.replace(/\D/g, "") + if (digitos.length < 10) { + setErro("Informe o número com DDD (ex: 45999999999).") + return + } + const fd = new FormData() + fd.set("id", instancia.id) + fd.set("numero", digitos) + const r = await gerarCodigoPareamentoAction(fd) + aplicarResultadoQr(r) + router.refresh() + } + + async function reiniciar() { + setQrLocal(null) + setPairingLocal(null) + const fd = new FormData() + fd.set("id", instancia.id) + const r = await reiniciarInstanciaAction(fd) + aplicarResultadoQr(r) router.refresh() } @@ -403,15 +508,33 @@ function LinhaInstancia({ {STATUS_LABEL[instancia.status_conexao]} {!inativa && instancia.status_conexao !== "conectado" && ( - + <> + + + )} + ) : ( +
+

+ Informe o número (com DDD) que vai ser conectado — a Evolution gera + um código curto pra digitar no WhatsApp, sem precisar escanear nada. +

+
+ setNumeroCodigo(e.target.value)} + placeholder="Ex: 45999999999" + className="glass-input" + style={{ fontSize: 12, padding: "6px 10px", flex: 1, minWidth: 0 }} + /> + +
+
+ )} + + )} + + {aviso && ( +

{aviso}

+ )} {erro && (

{erro}

)} diff --git a/lib/crm-inbound.ts b/lib/crm-inbound.ts index e1aad74..31aed48 100644 --- a/lib/crm-inbound.ts +++ b/lib/crm-inbound.ts @@ -162,6 +162,7 @@ async function processarQrcodeUpdate( .from("crm_instancias") .update({ ultimo_qr: qr, + ultimo_pairing_code: null, status_conexao: "qrcode", updated_at: new Date().toISOString(), }) @@ -204,7 +205,8 @@ async function processarConnectionUpdate( } if (status === "conectado") { patch.conectado_em = agora - patch.ultimo_qr = null // QR ja foi usado, evita reexibir na UI + patch.ultimo_qr = null // QR/codigo ja foi usado, evita reexibir na UI + patch.ultimo_pairing_code = null } await db.from("crm_instancias").update(patch).eq("id", inst.id) diff --git a/lib/crm-instancias-actions.ts b/lib/crm-instancias-actions.ts index 5c71908..c6f0823 100644 --- a/lib/crm-instancias-actions.ts +++ b/lib/crm-instancias-actions.ts @@ -9,6 +9,7 @@ import { excluirInstanciaEvolution, } from "./evolution" import { CORES_INSTANCIA } from "./crm-cores" +import { normalizarTelefone } from "./crm-telefone" export interface CrmInstanciaRow { id: string @@ -21,6 +22,7 @@ export interface CrmInstanciaRow { cor: string status_conexao: "conectado" | "desconectado" | "qrcode" | "desconhecido" ultimo_qr: string | null + ultimo_pairing_code: string | null conectado_em: string | null ativo: boolean created_at: string @@ -123,6 +125,17 @@ export async function criarInstanciaAction( } const r = await criarInstanciaEvolution(instanceName) + if (r.ok && r.qrBase64) { + await db + .from("crm_instancias") + .update({ + ultimo_qr: r.qrBase64, + ultimo_pairing_code: null, + status_conexao: "qrcode", + updated_at: new Date().toISOString(), + }) + .eq("instance_name", instanceName) + } revalidarConexoes() if (!r.ok) { return { @@ -133,11 +146,20 @@ export async function criarInstanciaAction( return { ok: true } } -/** Pede à Evolution pra (re)gerar o QR. O QR em si chega pelo webhook - * (QRCODE_UPDATED) e fica em crm_instancias.ultimo_qr. */ -export async function gerarQrAction( - formData: FormData -): Promise { +export interface ResultadoQr extends ResultadoInstancia { + qrBase64?: string + pairingCode?: string +} + +/** + * Pede à Evolution pra (re)gerar o QR. A resposta do próprio connect já + * costuma trazer o QR — persiste direto aqui e devolve pro client mostrar na + * hora, sem depender só do webhook QRCODE_UPDATED (que pode atrasar ou nunca + * chegar se o webhook da VPS estiver mal configurado). O webhook segue + * valendo como atualização em segundo plano (ex: QR expira e a Evolution + * manda um novo sozinha). + */ +export async function gerarQrAction(formData: FormData): Promise { const usuario = await getUsuarioAtual() if (!usuario) return { ok: false, erro: "Sessão expirada." } const db = getSupabaseAdmin() @@ -155,9 +177,192 @@ export async function gerarQrAction( if (!inst) return { ok: false, erro: "Instância não encontrada." } const r = await conectarInstanciaEvolution(inst.instance_name as string) + if (!r.ok) { + revalidarConexoes() + return { ok: false, erro: r.erro } + } + + if (r.qrBase64) { + await db + .from("crm_instancias") + .update({ + ultimo_qr: r.qrBase64, + ultimo_pairing_code: null, + status_conexao: "qrcode", + updated_at: new Date().toISOString(), + }) + .eq("id", id) + } revalidarConexoes() - if (!r.ok) return { ok: false, erro: r.erro } - return { ok: true } + + if (!r.qrBase64) { + return { + ok: true, + erro: + "A Evolution aceitou o pedido mas não devolveu o QR na hora — aguardando o webhook confirmar (alguns segundos). Se não aparecer, tente Recarregar/Reiniciar.", + } + } + return { ok: true, qrBase64: r.qrBase64 } +} + +/** + * Segunda opção de login: código de pareamento — a Evolution troca o QR por + * um código curto que o usuário digita no próprio WhatsApp (Aparelhos + * conectados → Conectar com número de telefone → Digitar código), sem + * precisar escanear nada. Existe pra quando o QR não aparece de jeito + * nenhum (câmera com problema, escaneamento falhando etc) — o usuário nunca + * fica na mão. + */ +export async function gerarCodigoPareamentoAction( + formData: FormData +): Promise { + const usuario = await getUsuarioAtual() + if (!usuario) return { ok: false, erro: "Sessão expirada." } + const db = getSupabaseAdmin() + if (!db) return { ok: false, erro: "Supabase indisponível." } + + const id = String(formData.get("id") ?? "").trim() + const numero = normalizarTelefone(String(formData.get("numero") ?? "")) + if (!id) return { ok: false, erro: "ID inválido." } + if (!numero) return { ok: false, erro: "Número inválido (informe com DDD)." } + + const { data: inst } = await db + .from("crm_instancias") + .select("instance_name") + .eq("id", id) + .eq("usuario_id", usuario.id) + .maybeSingle() + if (!inst) return { ok: false, erro: "Instância não encontrada." } + + const r = await conectarInstanciaEvolution(inst.instance_name as string, numero) + if (!r.ok) { + revalidarConexoes() + return { ok: false, erro: r.erro } + } + if (!r.pairingCode) { + revalidarConexoes() + return { + ok: false, + erro: "A Evolution não devolveu um código de pareamento — confira o número e tente de novo.", + } + } + + await db + .from("crm_instancias") + .update({ + ultimo_pairing_code: r.pairingCode, + ultimo_qr: null, + status_conexao: "qrcode", + updated_at: new Date().toISOString(), + }) + .eq("id", id) + revalidarConexoes() + + return { ok: true, pairingCode: r.pairingCode } +} + +/** + * "Recarregar/Reiniciar conexão" — pra quando a instância fica travada + * esperando QR e nada resolve (sessão Baileys emperrada do lado da + * Evolution). Desloga + apaga a sessão na Evolution (best-effort, igual + * excluirInstanciaAction) e recria do zero, já puxando um QR novo — o + * usuário não precisa ficar excluindo/recriando a instância manualmente. + */ +export async function reiniciarInstanciaAction( + formData: FormData +): Promise { + const usuario = await getUsuarioAtual() + if (!usuario) return { ok: false, erro: "Sessão expirada." } + const db = getSupabaseAdmin() + if (!db) return { ok: false, erro: "Supabase indisponível." } + + const id = String(formData.get("id") ?? "").trim() + if (!id) return { ok: false, erro: "ID inválido." } + + const { data: inst } = await db + .from("crm_instancias") + .select("instance_name") + .eq("id", id) + .eq("usuario_id", usuario.id) + .maybeSingle() + if (!inst) return { ok: false, erro: "Instância não encontrada." } + const instanceName = inst.instance_name as string + + await db + .from("crm_instancias") + .update({ + ultimo_qr: null, + ultimo_pairing_code: null, + status_conexao: "desconhecido", + updated_at: new Date().toISOString(), + }) + .eq("id", id) + revalidarConexoes() + + // Best-effort: se a sessão já não existir do lado da Evolution (nunca + // chegou a conectar, por exemplo), ignora e segue pro recreate mesmo assim. + await excluirInstanciaEvolution(instanceName) + + const criado = await criarInstanciaEvolution(instanceName) + if (!criado.ok) { + revalidarConexoes() + return { ok: false, erro: `Falha ao recriar na Evolution: ${criado.erro}` } + } + + let qrBase64 = criado.qrBase64 + if (!qrBase64) { + const conectado = await conectarInstanciaEvolution(instanceName) + if (conectado.ok) qrBase64 = conectado.qrBase64 + } + + if (qrBase64) { + await db + .from("crm_instancias") + .update({ + ultimo_qr: qrBase64, + status_conexao: "qrcode", + updated_at: new Date().toISOString(), + }) + .eq("id", id) + } + revalidarConexoes() + + if (!qrBase64) { + return { + ok: true, + erro: + "Instância reiniciada, mas o QR ainda não veio — aguarde o webhook ou clique em Gerar QR de novo em alguns segundos.", + } + } + return { ok: true, qrBase64 } +} + +export interface StatusInstancia { + status_conexao: CrmInstanciaRow["status_conexao"] + ultimo_qr: string | null + ultimo_pairing_code: string | null +} + +/** Leitura leve pro polling do client enquanto aguarda o usuário escanear o + * QR/digitar o código — evita esperar só pelo Realtime (que pode estar + * desligado silenciosamente, ver components/crm/CrmRealtime.tsx) ou por um + * router.refresh() manual. */ +export async function buscarStatusInstanciaAction( + id: string +): Promise { + const usuario = await getUsuarioAtual() + if (!usuario) return null + const db = getSupabaseAdmin() + if (!db) return null + + const { data } = await db + .from("crm_instancias") + .select("status_conexao, ultimo_qr, ultimo_pairing_code") + .eq("id", id) + .eq("usuario_id", usuario.id) + .maybeSingle() + if (!data) return null + return data as StatusInstancia } export async function desativarInstanciaAction( diff --git a/lib/crm-leads-actions.ts b/lib/crm-leads-actions.ts index 7ecdd7b..5698dd3 100644 --- a/lib/crm-leads-actions.ts +++ b/lib/crm-leads-actions.ts @@ -10,22 +10,13 @@ import { } from "./evolution" import { buscarEtapasAutomaticas, sincronizarEtiquetaComEtapa } from "./crm-etapas" import type { CampoPersonalizado } from "./crm-leads" +import { normalizarTelefone } from "./crm-telefone" export interface ResultadoLead { ok: boolean erro?: string } -/** Digitos puros; numero brasileiro sem DDI (10/11 digitos — fixo ou celular - * com o 9) ganha o 55 na frente, pro mesmo padrao dos leads que chegam pelo - * WhatsApp (telefoneDoJid em lib/crm-inbound.ts). */ -function normalizarTelefone(bruto: string): string | null { - const digitos = bruto.replace(/\D/g, "") - if (digitos.length < 8) return null - if (digitos.length <= 11) return `55${digitos}` - return digitos -} - function emailValido(bruto: string): string | null { const limpo = bruto.trim() if (!limpo) return null diff --git a/lib/crm-telefone.ts b/lib/crm-telefone.ts new file mode 100644 index 0000000..b31a970 --- /dev/null +++ b/lib/crm-telefone.ts @@ -0,0 +1,14 @@ +// Normalização de telefone compartilhada entre lib/crm-leads-actions.ts e +// lib/crm-instancias-actions.ts. Módulo plano (sem "use server") — as duas +// chamadoras são "use server" e só podem exportar funções async, então essa +// função pura precisa morar fora delas pra ser reaproveitada nas duas. + +/** Digitos puros; numero brasileiro sem DDI (10/11 digitos — fixo ou celular + * com o 9) ganha o 55 na frente, pro mesmo padrao dos leads que chegam pelo + * WhatsApp (telefoneDoJid em lib/crm-inbound.ts). */ +export function normalizarTelefone(bruto: string): string | null { + const digitos = bruto.replace(/\D/g, "") + if (digitos.length < 8) return null + if (digitos.length <= 11) return `55${digitos}` + return digitos +} diff --git a/lib/evolution.ts b/lib/evolution.ts index 8172150..c90be8a 100644 --- a/lib/evolution.ts +++ b/lib/evolution.ts @@ -386,14 +386,15 @@ async function extrairErroResposta(resp: Response): Promise { } /** - * Cria uma instancia nova na Evolution API. NAO retorna o QR aqui — o QR - * chega pelo evento QRCODE_UPDATED do webhook (lib/crm-inbound.ts), que - * persiste em crm_instancias.ultimo_qr. Chamar antes disso - * crm_instancias.insert() com o mesmo instanceName. + * Cria uma instancia nova na Evolution API. A resposta do create já costuma + * trazer o QR junto (mesmo formato do connect, ver conectarInstanciaEvolution) + * — lê direto daqui pra já cair conectando sem precisar de um segundo clique + * em "Gerar QR". O evento QRCODE_UPDATED do webhook (lib/crm-inbound.ts) + * segue valendo como atualização em segundo plano. */ export async function criarInstanciaEvolution( instanceName: string -): Promise { +): Promise { const base = process.env.EVOLUTION_API_URL const apikey = process.env.EVOLUTION_API_KEY if (!base || !apikey) { @@ -415,14 +416,24 @@ export async function criarInstanciaEvolution( signal: AbortSignal.timeout(15_000), cache: "no-store", }) + const json: unknown = await resp.json().catch(() => null) if (!resp.ok) { - const msg = await extrairErroResposta(resp) + const obj = (json ?? {}) as Record + const msg = + (typeof obj.message === "string" && obj.message) || + (typeof obj.error === "string" && obj.error) || + `HTTP ${resp.status}` console.error( - `[evolution] falha instance/create (${instanceName}): ${resp.status} ${msg}` + `[evolution] falha instance/create (${instanceName}): ${resp.status} ${JSON.stringify(json)}` ) - return { ok: false, erro: msg, status: resp.status } + return { ok: false, erro: String(msg), status: resp.status } } - return { ok: true, status: resp.status } + // O QR do create vem aninhado em { qrcode: {...} } na maioria dos builds + // (diferente do connect, que às vezes vem achatado) — checa os dois. + const obj = (json ?? {}) as Record + const qrObj = (obj.qrcode ?? obj) as Record + const qrBase64 = typeof qrObj.base64 === "string" ? qrObj.base64 : undefined + return { ok: true, status: resp.status, qrBase64 } } catch (e) { const msg = e instanceof Error ? e.message : String(e) console.error( @@ -432,14 +443,33 @@ export async function criarInstanciaEvolution( } } +export interface ConexaoEvolution extends ResultadoInstanciaEvolution { + /** Data URL (data:image/png;base64,...) do QR — vem no próprio corpo da + * resposta do connect quando `numeroE164` NÃO é passado. */ + qrBase64?: string + /** Código de pareamento (o usuário digita no WhatsApp em vez de escanear) + * — vem no corpo da resposta quando `numeroE164` É passado. */ + pairingCode?: string +} + /** - * Dispara/renova a geracao do QR de uma instancia ja criada. A fonte de - * verdade do QR continua sendo o evento QRCODE_UPDATED do webhook — esta - * funcao so pede pra Evolution gerar um novo (ex: QR expirou). + * Dispara/renova a geracao do QR (ou, se `numeroE164` for passado, do CÓDIGO + * DE PAREAMENTO — login digitando o código no WhatsApp em vez de escanear) + * de uma instancia ja criada. + * + * O evento QRCODE_UPDATED do webhook (lib/crm-inbound.ts) segue valendo como + * atualização em segundo plano (ex: o QR expira e a Evolution manda um novo + * sozinha), mas NÃO é mais a única fonte: a própria resposta deste GET já + * costuma trazer o QR/código no corpo — antes essa função só conferia + * resp.ok e descartava o corpo, then dependia 100% do webhook chegar a + * tempo (e ele podia nunca chegar: webhook mal configurado na VPS, ou o + * Realtime do Supabase silenciosamente desligado no client). Ler direto + * daqui faz o botão funcionar mesmo se esse segundo canal falhar. */ export async function conectarInstanciaEvolution( - instanceName: string -): Promise { + instanceName: string, + numeroE164?: string +): Promise { const base = process.env.EVOLUTION_API_URL const apikey = process.env.EVOLUTION_API_KEY if (!base || !apikey) { @@ -447,9 +477,10 @@ export async function conectarInstanciaEvolution( return { ok: false, erro: "credenciais_ausentes" } } + const query = numeroE164 ? `?number=${encodeURIComponent(numeroE164)}` : "" const url = `${base.replace(/\/$/, "")}/instance/connect/${encodeURIComponent( instanceName - )}` + )}${query}` try { const resp = await fetch(url, { @@ -458,14 +489,29 @@ export async function conectarInstanciaEvolution( signal: AbortSignal.timeout(15_000), cache: "no-store", }) + const json: unknown = await resp.json().catch(() => null) if (!resp.ok) { - const msg = await extrairErroResposta(resp) + const obj = (json ?? {}) as Record + const msg = + (typeof obj.message === "string" && obj.message) || + (typeof obj.error === "string" && obj.error) || + `HTTP ${resp.status}` console.error( - `[evolution] falha instance/connect (${instanceName}): ${resp.status} ${msg}` + `[evolution] falha instance/connect (${instanceName}): ${resp.status} ${JSON.stringify(json)}` ) - return { ok: false, erro: msg, status: resp.status } + return { ok: false, erro: String(msg), status: resp.status } } - return { ok: true, status: resp.status } + + // Formato varia entre builds da Evolution: às vezes "achatado" (base64/ + // pairingCode direto na raiz), às vezes aninhado em { qrcode: {...} } — + // mesma tolerância de extrairQrBase64 em lib/crm-inbound.ts. + const obj = (json ?? {}) as Record + const qrObj = (obj.qrcode ?? obj) as Record + const qrBase64 = typeof qrObj.base64 === "string" ? qrObj.base64 : undefined + const pairingCode = + typeof qrObj.pairingCode === "string" ? qrObj.pairingCode : undefined + + return { ok: true, status: resp.status, qrBase64, pairingCode } } catch (e) { const msg = e instanceof Error ? e.message : String(e) console.error( diff --git a/supabase/migrations/20260717_crm_instancias_pairing_code.sql b/supabase/migrations/20260717_crm_instancias_pairing_code.sql new file mode 100644 index 0000000..13da9d9 --- /dev/null +++ b/supabase/migrations/20260717_crm_instancias_pairing_code.sql @@ -0,0 +1,8 @@ +-- CRM — login por código de pareamento como alternativa ao QR (Evolution +-- API já suporta isso via /instance/connect/{instance}?number=...). 2026-07-17. +-- +-- Guarda o código junto do QR (ultimo_qr) na mesma linha — os dois nunca +-- coexistem (gerar um limpa o outro), então não precisa de tabela separada. + +alter table public.crm_instancias + add column if not exists ultimo_pairing_code text;