@adatechnology/conversations-ui 0.1.0-rc.3 → 0.1.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,122 @@
1
1
  import type { MessagePayload } from '../types'
2
2
  import type { ConversationChannel } from '../conversationChannel'
3
3
 
4
+ export interface ListConversationsParams {
5
+ page?: number
6
+ limit?: number
7
+ waitingHuman?: boolean
8
+ search?: string
9
+ /**
10
+ * Recortes que só o produto conhece (tipo de financiamento, carteira, campanha) repassados
11
+ * crus ao backend dele. É o que evita o vocabulário de uma vertical virar campo fixo aqui:
12
+ * o pacote transporta o filtro sem saber o que ele significa.
13
+ */
14
+ filters?: Record<string, string | undefined>
15
+ }
16
+
17
+ /**
18
+ * Página com o total, para a UI conseguir desenhar controles de paginação.
19
+ *
20
+ * `fetchConversations` devolve isto **ou** o array puro de antes: implementações existentes
21
+ * continuam válidas sem mudar uma linha, e quem precisa paginar passa a ter o total. Sem a união
22
+ * seria impossível saber se um retorno curto é a última página ou uma página cheia por acaso.
23
+ */
24
+ export interface ConversationPage {
25
+ conversations: ConversationSummary[]
26
+ total: number
27
+ }
28
+
29
+ export interface ListDocumentsParams {
30
+ search?: string
31
+ page?: number
32
+ /** Tamanho da página. Sem ele, `page` sozinho não define fatia nenhuma. */
33
+ limit?: number
34
+ /** Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host. */
35
+ source?: string
36
+ sortDirection?: 'asc' | 'desc'
37
+ }
38
+
39
+ export interface ConversationDocumentPage {
40
+ documents: ConversationDocument[]
41
+ total: number
42
+ }
43
+
44
+ /**
45
+ * Template disponível para envio a partir da inbox. Distinto do `WhatsAppTemplateSummary` de
46
+ * `settings/`, e de propósito: aquele serve ao formulário que **edita** template e carrega o que
47
+ * a edição precisa (`shortId`, `variableCount`); este serve a quem só vai **escolher um para
48
+ * enviar**, e pedir os campos de edição obrigaria todo host a produzi-los sem uso.
49
+ */
50
+ export interface ConversationTemplate {
51
+ name: string
52
+ language: string
53
+ status: string
54
+ category?: string
55
+ bodyText?: string | null
56
+ }
57
+
4
58
  export interface ConversationsApi {
5
59
  fetchMessages(conversationId: string, params?: { limit?: number; before?: string }): Promise<MessagePayload[]>
6
- fetchConversations(params?: {
7
- page?: number
8
- limit?: number
9
- waitingHuman?: boolean
10
- search?: string
11
- }): Promise<ConversationSummary[]>
60
+ fetchConversations(params?: ListConversationsParams): Promise<ConversationSummary[] | ConversationPage>
12
61
  sendMessage(conversationId: string, text: string): Promise<MessagePayload>
13
62
  sendMedia(
14
63
  conversationId: string,
15
64
  data: { base64: string; mimeType: string; filename: string; caption?: string },
16
65
  ): Promise<MessagePayload>
66
+ /**
67
+ * `templateName` é opcional porque reabrir a janela é a operação, e escolher *qual* template a
68
+ * usa nem sempre é decisão da UI: backends que guardam um template padrão configurado só
69
+ * precisam do "reabra". Exigir o nome obrigaria toda inbox a listar templates antes de poder
70
+ * mandar o primeiro — e a listagem é `listTemplates?`, opcional.
71
+ */
17
72
  sendTemplate(
18
73
  conversationId: string,
19
- data: { templateName: string; languageCode?: string; bodyParams?: string[] },
74
+ data: { templateName?: string; languageCode?: string; bodyParams?: string[] },
20
75
  ): Promise<void>
21
76
  markRead(conversationId: string): Promise<void>
22
77
  getContext(conversationId: string): Promise<Record<string, unknown>>
23
- getDocuments(conversationId: string, params?: { search?: string; page?: number }): Promise<ConversationDocument[]>
24
- getDocumentUrl(uploadId: string): Promise<string>
78
+ getDocuments(
79
+ conversationId: string,
80
+ params?: ListDocumentsParams,
81
+ ): Promise<ConversationDocument[] | ConversationDocumentPage>
82
+ /**
83
+ * `disposition` decide entre abrir no navegador e baixar. É o backend que assina a URL e grava
84
+ * o `Content-Disposition` nela, então a escolha precisa viajar na chamada — depois de assinada
85
+ * não há como o cliente mudá-la. Ausente = o padrão do host.
86
+ */
87
+ getDocumentUrl(uploadId: string, disposition?: 'inline' | 'attachment'): Promise<string>
88
+ /**
89
+ * Baixa vários arquivos num zip único.
90
+ *
91
+ * **Opcional por capacidade:** montar zip exige o host LER os bytes do storage, o que nem toda
92
+ * instalação faz — as que só assinam URL não conseguem. Ausente, o painel esconde a seleção em
93
+ * lote em vez de oferecer um botão que falha.
94
+ */
95
+ downloadDocumentsArchive?(conversationId: string, uploadIds: readonly string[]): Promise<Blob>
25
96
  getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
97
+
98
+ /**
99
+ * Operações de atendimento humano. **Opcionais por capacidade, não por descuido:** nem toda
100
+ * inbox tem fila humana — um canal só-bot, ou um chat de site sem operador, não sabe o que é
101
+ * assumir conversa. Quem não implementa não ganha o botão, em vez de ganhar um botão que
102
+ * estoura no clique. Os hooks devolvem `undefined` para a ação ausente, e é isso que a UI
103
+ * consulta para decidir se desenha a afordância.
104
+ */
105
+ takeover?(conversationId: string): Promise<void>
106
+ release?(conversationId: string): Promise<void>
107
+ /** Encerra o atendimento. Despedida, se houver, é decisão do host — o pacote não a inventa. */
108
+ finalize?(conversationId: string): Promise<void>
109
+
110
+ markAllRead?(): Promise<void>
111
+ listTemplates?(): Promise<ConversationTemplate[]>
112
+
113
+ /**
114
+ * Transcrição completa gerada pelo servidor. Existe ao lado de `buildTranscriptText`, que monta
115
+ * a partir das mensagens já em memória: a tela costuma ter só a última página carregada, e
116
+ * exportar dali entregaria um recorte parcial com cara de histórico inteiro. Opcional porque
117
+ * nem todo backend expõe a rota — quem não tem continua usando o builder local.
118
+ */
119
+ exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
26
120
  }
27
121
 
28
122
  /**
@@ -76,6 +170,13 @@ export interface ConversationSummary {
76
170
  waitingHuman: boolean
77
171
  unread: number
78
172
  currentState: string
173
+ /**
174
+ * Atributos que só o produto conhece e desenha (tipo de financiamento, carteira, campanha). É a
175
+ * contraparte de leitura do `filters` de `ListConversationsParams`: o pacote transporta e nunca
176
+ * interpreta. Sem isto, exibir um selo próprio na linha exigiria o host manter uma segunda
177
+ * consulta paralela à mesma listagem — a implementação duplicada que o pacote existe para evitar.
178
+ */
179
+ attributes?: Record<string, string | undefined>
79
180
  }
80
181
 
81
182
  export interface ConversationDocument {
@@ -1,64 +1,109 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { useConversations } from './providers/ConversationsProvider'
3
- import type { ConversationSummary } from './providers/types'
3
+ import { conversationsOf } from './lib/paginated'
4
+ import type { ConversationSummary, ListConversationsParams } from './providers/types'
4
5
 
5
- interface UseWaitingNotificationsResult {
6
+ const DEFAULT_POLL_INTERVAL_MS = 10_000
7
+ const DEFAULT_LIMIT = 50
8
+
9
+ export interface UseWaitingNotificationsLabels {
10
+ /** Título da notificação do sistema. Recebe a conversa para o host escolher nome × número. */
11
+ title: (conversation: ConversationSummary) => string
12
+ body: (conversation: ConversationSummary) => string
13
+ }
14
+
15
+ export interface UseWaitingNotificationsParams {
16
+ /**
17
+ * Repassado cru ao `fetchConversations`. É o que permite filtrar não lidas **no servidor** em
18
+ * vez de baixar a lista inteira e contar no cliente: um painel com milhares de conversas não
19
+ * pode paginar 50 por vez atrás de quem tem `unread > 0`.
20
+ */
21
+ readonly params?: ListConversationsParams
22
+ readonly intervalMs?: number
23
+ /** Desliga o polling sem desmontar quem chama — útil com a aba em segundo plano. */
24
+ readonly enabled?: boolean
25
+ readonly icon?: string
26
+ readonly labels?: Partial<UseWaitingNotificationsLabels>
27
+ }
28
+
29
+ export interface UseWaitingNotificationsResult {
6
30
  unreadCount: number
7
31
  conversations: ConversationSummary[]
32
+ /**
33
+ * Releitura sob demanda. Existe porque o polling é o piso, não o mecanismo: quem já recebe SSE
34
+ * ou acabou de marcar tudo como lido sabe da mudança antes do próximo tick, e esperar 10s para
35
+ * o contador acompanhar faz a interface parecer travada.
36
+ */
37
+ refresh: () => Promise<void>
38
+ }
39
+
40
+ const DEFAULT_LABELS: UseWaitingNotificationsLabels = {
41
+ title: (conversation) => conversation.clientName ?? conversation.whatsappNumber,
42
+ body: (conversation) => conversation.lastContent ?? 'Nova mensagem',
8
43
  }
9
44
 
10
- export function useWaitingNotifications(): UseWaitingNotificationsResult {
11
- const conversationsCtx = useConversations()
45
+ export function useWaitingNotifications(params?: UseWaitingNotificationsParams): UseWaitingNotificationsResult {
46
+ const conversationsContext = useConversations()
12
47
  const [conversations, setConversations] = useState<ConversationSummary[]>([])
13
48
  const previousUnreadMap = useRef<Map<string, number>>(new Map())
14
49
  const notifiedIds = useRef<Set<string>>(new Set())
50
+ // Trava de reentrância: com a rede lenta, o tick de 10s dispara sobre a busca anterior ainda em
51
+ // voo, e duas respostas fora de ordem fazem o contador oscilar e a notificação repetir.
52
+ const isPollingRef = useRef(false)
53
+
54
+ const isEnabled = params?.enabled ?? true
55
+ const intervalMs = params?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS
56
+ const icon = params?.icon
57
+ const listParams = params?.params
58
+ const labelTitle = params?.labels?.title ?? DEFAULT_LABELS.title
59
+ const labelBody = params?.labels?.body ?? DEFAULT_LABELS.body
15
60
 
16
- const pollConversations = useCallback(async () => {
17
- if (!conversationsCtx) return
61
+ const refresh = useCallback(async () => {
62
+ if (!conversationsContext || isPollingRef.current) return
63
+ isPollingRef.current = true
18
64
 
19
65
  try {
20
- const result = await conversationsCtx.api.fetchConversations({ limit: 50 })
66
+ const result = conversationsOf(
67
+ await conversationsContext.api.fetchConversations({ limit: DEFAULT_LIMIT, ...listParams }),
68
+ )
21
69
  setConversations(result)
22
70
 
23
71
  const currentMap = new Map<string, number>()
24
- for (const conv of result) {
25
- currentMap.set(conv.id, conv.unread)
26
- }
72
+ for (const conversation of result) currentMap.set(conversation.id, conversation.unread)
27
73
 
28
- for (const conv of result) {
29
- if (conv.unread <= 0) continue
74
+ for (const conversation of result) {
75
+ if (conversation.unread <= 0) continue
30
76
 
31
- const prev = previousUnreadMap.current.get(conv.id) ?? 0
77
+ const previousUnread = previousUnreadMap.current.get(conversation.id) ?? 0
78
+ if (conversation.unread <= previousUnread || notifiedIds.current.has(conversation.id)) continue
32
79
 
33
- if (conv.unread > prev && !notifiedIds.current.has(conv.id)) {
34
- notifiedIds.current.add(conv.id)
35
-
36
- if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
37
- const title = conv.clientName ?? conv.whatsappNumber
38
- const body = conv.lastContent ?? 'Nova mensagem'
39
- new Notification(title, { body, icon: '/favicon.ico' })
40
- }
80
+ notifiedIds.current.add(conversation.id)
81
+ if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
82
+ new Notification(labelTitle(conversation), { body: labelBody(conversation), ...(icon ? { icon } : {}) })
41
83
  }
42
84
  }
43
85
 
44
86
  previousUnreadMap.current = currentMap
45
87
  } catch {
46
- // Silently ignore polling errors
88
+ // Contador de badge não interrompe o atendimento: uma falha de rede some no próximo tick.
89
+ } finally {
90
+ isPollingRef.current = false
47
91
  }
48
- }, [conversationsCtx])
92
+ }, [conversationsContext, listParams, icon, labelTitle, labelBody])
49
93
 
50
94
  useEffect(() => {
95
+ if (!isEnabled) return
96
+
51
97
  if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'default') {
52
98
  Notification.requestPermission()
53
99
  }
54
100
 
55
- pollConversations()
56
-
57
- const interval = setInterval(pollConversations, 10_000)
101
+ void refresh()
102
+ const interval = setInterval(() => void refresh(), intervalMs)
58
103
  return () => clearInterval(interval)
59
- }, [pollConversations])
104
+ }, [refresh, isEnabled, intervalMs])
60
105
 
61
- const unreadCount = conversations.reduce((sum, conv) => sum + conv.unread, 0)
106
+ const unreadCount = conversations.reduce((sum, conversation) => sum + conversation.unread, 0)
62
107
 
63
- return { unreadCount, conversations }
108
+ return { unreadCount, conversations, refresh }
64
109
  }