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:
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user