From dac3198873cd045836c62458fdd929ff1346f4f2 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Tue, 28 Apr 2026 17:11:44 -0300 Subject: [PATCH] Drawer WhatsApp: banner persistente em erros de envio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit friendlySendError (string única) virou classifySendError, que devolve { code, status, message, hint, action, secondaryAction }. UI passa a renderizar banner persistente no chat (não só toast efêmero) com título + dica explicativa + CTA contextual. Casos cobertos: - 502/503/504 -> "Servidor de WhatsApp fora do ar" + CTA Configurar + CTA Comprar créditos (caso ainda não tenha contratado) - insufficient_credits -> CTA Comprar créditos - canal nao configurado / inativo -> CTA Configurar agora - credenciais evolution incompletas -> CTA Configuracoes WhatsApp - twilio credenciais incompletas -> sem CTA (fala pra contatar suporte) - evolution retornou ... -> CTA Ver status - twilio_send_failed... -> CTA Configuracoes WhatsApp - auth -> "sessao expirou", sem CTA - forbidden -> sem CTA Co-Authored-By: Claude Opus 4.7 (1M context) --- .../conversations/ConversationDrawer.vue | 39 ++++ src/stores/conversationDrawerStore.js | 168 +++++++++++++++--- 2 files changed, 186 insertions(+), 21 deletions(-) diff --git a/src/components/conversations/ConversationDrawer.vue b/src/components/conversations/ConversationDrawer.vue index 33b459e..d8a0389 100644 --- a/src/components/conversations/ConversationDrawer.vue +++ b/src/components/conversations/ConversationDrawer.vue @@ -1103,6 +1103,45 @@ function insertEmoji(emoji) { + +
+ +
+
{{ store.lastSendError.message }}
+
+ {{ store.lastSendError.hint }} +
+
+ + {{ store.lastSendError.action.label }} + + + {{ store.lastSendError.secondaryAction.label }} + +
+
+ +
+
diff --git a/src/stores/conversationDrawerStore.js b/src/stores/conversationDrawerStore.js index 7252fc0..6e31e8a 100644 --- a/src/stores/conversationDrawerStore.js +++ b/src/stores/conversationDrawerStore.js @@ -14,22 +14,125 @@ import { defineStore } from 'pinia'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; -// Mapeia códigos de erro do edge function pra mensagens amigáveis ao usuário. -// O edge function retorna { ok: false, error: '', message: '' } — priorizamos message. -function friendlySendError(code, providedMessage) { - if (providedMessage) return providedMessage; +// Classifica o erro do envio em { message, hint, action } pra UI mostrar +// um banner persistente no chat (não só toast). Diferente do friendly antigo +// que retornava só string, esse devolve estrutura — assim o componente pode +// renderizar título + dica explicativa + botão de ação contextual. +// +// Inputs: +// code — string vinda de error.error / data.error +// providedMessage — string vinda de error.message / data.message +// status — HTTP status (error.context?.status) — captura 502/503/504 +// +// O edge function retorna { ok: false, error: '', message: '' }. +function classifySendError(code, providedMessage, status) { const c = String(code || '').toLowerCase(); - if (c === 'insufficient_credits') return 'Saldo de créditos insuficiente. Compre um pacote em Configurações → Créditos WhatsApp.'; - if (c.startsWith('twilio_send_failed')) return 'Não conseguimos enviar pelo WhatsApp oficial. Verifique se o canal está conectado em Configurações → WhatsApp.'; - if (c.includes('canal whatsapp nao configurado') || c.includes('canal whatsapp inativo')) return 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.'; - if (c.includes('credenciais evolution incompletas')) return 'As credenciais do WhatsApp Pessoal estão incompletas. Acesse Configurações → WhatsApp.'; - if (c.includes('twilio credenciais incompletas')) return 'As credenciais do WhatsApp Oficial estão incompletas. Contate o suporte.'; - if (c.startsWith('evolution retornou')) return 'O servidor do WhatsApp Pessoal não respondeu. Verifique se o celular está conectado.'; - if (c === 'auth') return 'Sua sessão expirou. Faça login novamente.'; - if (c === 'forbidden') return 'Você não tem permissão pra enviar por este canal.'; - if (c === 'edge function returned a non-2xx status code') return 'O servidor recusou o envio. Tente novamente ou contate o suporte.'; - // Fallback: retorna o código original se nada bateu - return code || 'Falha ao enviar. Tente novamente.'; + const hasMsg = !!providedMessage; + const httpStatus = Number(status) || null; + + // ── 5xx (gateway/server offline): caso típico de Evolution/Twilio fora ── + // Quando a edge function de envio retorna 502/503/504, normalmente o + // provedor (Evolution self-hosted ou Twilio) está fora ou inalcançável. + if (httpStatus === 502 || httpStatus === 503 || httpStatus === 504) { + return { + code: c || `http_${httpStatus}`, + status: httpStatus, + message: 'Servidor de WhatsApp temporariamente fora do ar.', + hint: 'Os envios costumam voltar em poucos minutos. Se você ainda não configurou ou contratou o serviço, verifique abaixo.', + action: { label: 'Configurar WhatsApp', url: '/configuracoes/whatsapp' }, + secondaryAction: { label: 'Comprar créditos', url: '/configuracoes/creditos-whatsapp' } + }; + } + + if (c === 'insufficient_credits') { + return { + code: c, status: httpStatus, + message: 'Saldo de créditos insuficiente.', + hint: 'Você precisa de créditos pra enviar mensagens pelo WhatsApp Oficial.', + action: { label: 'Comprar créditos', url: '/configuracoes/creditos-whatsapp' } + }; + } + + if (c.includes('canal whatsapp nao configurado') || c.includes('canal whatsapp inativo')) { + return { + code: c, status: httpStatus, + message: 'Nenhum canal WhatsApp ativo.', + hint: 'Você ainda não configurou um número. Conecte um WhatsApp Pessoal (gratuito) ou contrate o WhatsApp Oficial.', + action: { label: 'Configurar agora', url: '/configuracoes/whatsapp' } + }; + } + + if (c.includes('credenciais evolution incompletas')) { + return { + code: c, status: httpStatus, + message: 'WhatsApp Pessoal está com credenciais incompletas.', + hint: 'Termine a conexão do seu número (escaneie o QR Code novamente, se necessário).', + action: { label: 'Configurações WhatsApp', url: '/configuracoes/whatsapp' } + }; + } + + if (c.includes('twilio credenciais incompletas')) { + return { + code: c, status: httpStatus, + message: 'WhatsApp Oficial está com credenciais incompletas.', + hint: 'Contate o suporte pra finalizar a configuração da sua conta Twilio.', + action: null + }; + } + + if (c.startsWith('evolution retornou')) { + return { + code: c, status: httpStatus, + message: 'WhatsApp Pessoal não respondeu.', + hint: 'Verifique se o celular conectado está com internet e a sessão do WhatsApp ativa.', + action: { label: 'Ver status', url: '/configuracoes/whatsapp' } + }; + } + + if (c.startsWith('twilio_send_failed')) { + return { + code: c, status: httpStatus, + message: 'WhatsApp Oficial recusou o envio.', + hint: 'Verifique se o canal está conectado e se o número de destino aceita mensagens template fora da janela de 24h.', + action: { label: 'Configurações WhatsApp', url: '/configuracoes/whatsapp' } + }; + } + + if (c === 'auth') { + return { + code: c, status: httpStatus, + message: 'Sua sessão expirou.', + hint: 'Faça login novamente pra continuar.', + action: null + }; + } + + if (c === 'forbidden') { + return { + code: c, status: httpStatus, + message: 'Você não tem permissão pra enviar por este canal.', + hint: 'Verifique seu plano ou contate o admin do tenant.', + action: null + }; + } + + if (c === 'edge function returned a non-2xx status code') { + return { + code: c, status: httpStatus, + message: 'O servidor recusou o envio.', + hint: 'Tente novamente em instantes. Se persistir, verifique configuração ou contate o suporte.', + action: { label: 'Configurar WhatsApp', url: '/configuracoes/whatsapp' } + }; + } + + // Fallback genérico — usa providedMessage se houver, senão o código + return { + code: c || 'unknown', + status: httpStatus, + message: hasMsg ? providedMessage : 'Não foi possível enviar a mensagem.', + hint: 'Tente novamente em alguns instantes. Se persistir, verifique sua conexão e a configuração do WhatsApp.', + action: { label: 'Configurações WhatsApp', url: '/configuracoes/whatsapp' } + }; } export const useConversationDrawerStore = defineStore('conversationDrawer', { @@ -39,6 +142,9 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', { messages: [], loading: false, sending: false, + // Último erro de envio (estruturado, com hint + action) — fica no chat + // como banner persistente até user enviar com sucesso ou dispensar. + lastSendError: null, // { code, status, message, hint, action: { label, url }, secondaryAction? } error: null, _realtimeChannel: null, // cache compartilhado @@ -61,6 +167,8 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', { if (!thread) return; this.thread = { ...thread }; this.isOpen = true; + // Erros de envio são por-conversa — não vazam pra próxima + this.lastSendError = null; await this.loadMessages(); this._ensureTenantName(); this._subscribeRealtime(); @@ -216,7 +324,11 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', { async sendMessage(text) { const cleanText = String(text || '').trim(); if (!cleanText || this.sending) return { ok: false, error: 'Mensagem vazia' }; - if (!this.thread?.contact_number) return { ok: false, error: 'Conversa sem número de contato' }; + if (!this.thread?.contact_number) { + const cls = { code: 'no_contact', status: null, message: 'Conversa sem número de contato', hint: 'Vincule um paciente com telefone à conversa antes de enviar.', action: null }; + this.lastSendError = cls; + return { ok: false, error: cls.message, classification: cls }; + } const tenantStore = useTenantStore(); this.sending = true; @@ -230,27 +342,41 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', { } }); - // Erro HTTP (não-2xx) — extrai body da resposta pra mostrar msg amigável + // Erro HTTP (não-2xx) — extrai status + body da resposta if (error) { + const status = error.context?.status || null; let body = null; try { body = await error.context?.json?.(); - } catch { /* noop */ } - return { ok: false, error: friendlySendError(body?.error, body?.message) }; + } catch { /* noop — pode não ter body JSON em 502 */ } + const cls = classifySendError(body?.error, body?.message, status); + this.lastSendError = cls; + return { ok: false, error: cls.message, classification: cls }; } if (!data?.ok) { - return { ok: false, error: friendlySendError(data?.error, data?.message) }; + const cls = classifySendError(data?.error, data?.message, null); + this.lastSendError = cls; + return { ok: false, error: cls.message, classification: cls }; } + // Sucesso — limpa banner de erro anterior + this.lastSendError = null; this.thread.kanban_status = 'awaiting_patient'; return { ok: true, data }; } catch (err) { - return { ok: false, error: friendlySendError(err?.message || String(err)) }; + // Provavelmente erro de rede (fetch falhou). Sem `error.context`. + const cls = classifySendError(err?.message || String(err), null, null); + this.lastSendError = cls; + return { ok: false, error: cls.message, classification: cls }; } finally { this.sending = false; } }, + dismissSendError() { + this.lastSendError = null; + }, + async setKanbanStatus(status) { if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return; if (!this.thread) return;