Drawer WhatsApp: banner persistente em erros de envio

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) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-28 17:11:44 -03:00
parent a57cf27a6a
commit dac3198873
2 changed files with 186 additions and 21 deletions
@@ -1103,6 +1103,45 @@ function insertEmoji(emoji) {
</div> </div>
</div> </div>
<!-- Banner de erro de envio (persistente até user enviar com sucesso ou dispensar) -->
<div
v-if="store.lastSendError"
class="flex items-start gap-2.5 mt-2 px-3 py-2.5 rounded-lg border border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<i class="pi pi-exclamation-triangle text-amber-600 dark:text-amber-300 mt-0.5 shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-snug">{{ store.lastSendError.message }}</div>
<div v-if="store.lastSendError.hint" class="text-xs opacity-85 mt-0.5 leading-snug">
{{ store.lastSendError.hint }}
</div>
<div v-if="store.lastSendError.action || store.lastSendError.secondaryAction" class="flex flex-wrap gap-3 mt-1.5">
<RouterLink
v-if="store.lastSendError.action"
:to="store.lastSendError.action.url"
class="inline-flex items-center gap-1 text-xs font-semibold underline hover:no-underline"
@click="store.dismissSendError()"
>
{{ store.lastSendError.action.label }} <i class="pi pi-arrow-right text-[0.6rem]" />
</RouterLink>
<RouterLink
v-if="store.lastSendError.secondaryAction"
:to="store.lastSendError.secondaryAction.url"
class="inline-flex items-center gap-1 text-xs font-medium underline hover:no-underline opacity-90"
@click="store.dismissSendError()"
>
{{ store.lastSendError.secondaryAction.label }} <i class="pi pi-arrow-right text-[0.6rem]" />
</RouterLink>
</div>
</div>
<button
class="shrink-0 w-6 h-6 grid place-items-center rounded hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors"
title="Dispensar"
@click="store.dismissSendError()"
>
<i class="pi pi-times text-[0.7rem]" />
</button>
</div>
<!-- Compose --> <!-- Compose -->
<div v-if="store.thread.channel === 'whatsapp' && store.thread.contact_number" class="border-t border-[var(--surface-border)] pt-2 flex flex-col gap-2"> <div v-if="store.thread.channel === 'whatsapp' && store.thread.contact_number" class="border-t border-[var(--surface-border)] pt-2 flex flex-col gap-2">
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
+147 -21
View File
@@ -14,22 +14,125 @@ import { defineStore } from 'pinia';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// Mapeia códigos de erro do edge function pra mensagens amigáveis ao usuário. // Classifica o erro do envio em { message, hint, action } pra UI mostrar
// O edge function retorna { ok: false, error: '<code>', message: '<human>' } — priorizamos message. // um banner persistente no chat (não só toast). Diferente do friendly antigo
function friendlySendError(code, providedMessage) { // que retornava só string, esse devolve estrutura — assim o componente pode
if (providedMessage) return providedMessage; // 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: '<code>', message: '<human>' }.
function classifySendError(code, providedMessage, status) {
const c = String(code || '').toLowerCase(); const c = String(code || '').toLowerCase();
if (c === 'insufficient_credits') return 'Saldo de créditos insuficiente. Compre um pacote em Configurações → Créditos WhatsApp.'; const hasMsg = !!providedMessage;
if (c.startsWith('twilio_send_failed')) return 'Não conseguimos enviar pelo WhatsApp oficial. Verifique se o canal está conectado em Configurações → WhatsApp.'; const httpStatus = Number(status) || null;
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.'; // ── 5xx (gateway/server offline): caso típico de Evolution/Twilio fora ──
if (c.includes('twilio credenciais incompletas')) return 'As credenciais do WhatsApp Oficial estão incompletas. Contate o suporte.'; // Quando a edge function de envio retorna 502/503/504, normalmente o
if (c.startsWith('evolution retornou')) return 'O servidor do WhatsApp Pessoal não respondeu. Verifique se o celular está conectado.'; // provedor (Evolution self-hosted ou Twilio) está fora ou inalcançável.
if (c === 'auth') return 'Sua sessão expirou. Faça login novamente.'; if (httpStatus === 502 || httpStatus === 503 || httpStatus === 504) {
if (c === 'forbidden') return 'Você não tem permissão pra enviar por este canal.'; return {
if (c === 'edge function returned a non-2xx status code') return 'O servidor recusou o envio. Tente novamente ou contate o suporte.'; code: c || `http_${httpStatus}`,
// Fallback: retorna o código original se nada bateu status: httpStatus,
return code || 'Falha ao enviar. Tente novamente.'; 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', { export const useConversationDrawerStore = defineStore('conversationDrawer', {
@@ -39,6 +142,9 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
messages: [], messages: [],
loading: false, loading: false,
sending: 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, error: null,
_realtimeChannel: null, _realtimeChannel: null,
// cache compartilhado // cache compartilhado
@@ -61,6 +167,8 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
if (!thread) return; if (!thread) return;
this.thread = { ...thread }; this.thread = { ...thread };
this.isOpen = true; this.isOpen = true;
// Erros de envio são por-conversa — não vazam pra próxima
this.lastSendError = null;
await this.loadMessages(); await this.loadMessages();
this._ensureTenantName(); this._ensureTenantName();
this._subscribeRealtime(); this._subscribeRealtime();
@@ -216,7 +324,11 @@ export const useConversationDrawerStore = defineStore('conversationDrawer', {
async sendMessage(text) { async sendMessage(text) {
const cleanText = String(text || '').trim(); const cleanText = String(text || '').trim();
if (!cleanText || this.sending) return { ok: false, error: 'Mensagem vazia' }; 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(); const tenantStore = useTenantStore();
this.sending = true; 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) { if (error) {
const status = error.context?.status || null;
let body = null; let body = null;
try { try {
body = await error.context?.json?.(); body = await error.context?.json?.();
} catch { /* noop */ } } catch { /* noop — pode não ter body JSON em 502 */ }
return { ok: false, error: friendlySendError(body?.error, body?.message) }; const cls = classifySendError(body?.error, body?.message, status);
this.lastSendError = cls;
return { ok: false, error: cls.message, classification: cls };
} }
if (!data?.ok) { 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'; this.thread.kanban_status = 'awaiting_patient';
return { ok: true, data }; return { ok: true, data };
} catch (err) { } 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 { } finally {
this.sending = false; this.sending = false;
} }
}, },
dismissSendError() {
this.lastSendError = null;
},
async setKanbanStatus(status) { async setKanbanStatus(status) {
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return; if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return;
if (!this.thread) return; if (!this.thread) return;