@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.
- package/dist/{chunk-N7B24WYD.js → chunk-YWITIIHD.js} +35 -26
- package/dist/index.d.ts +109 -25
- package/dist/index.js +371 -60
- package/dist/preview/index.d.ts +17 -3
- package/dist/preview/index.js +324 -103
- package/dist/{types-C0PtaO7S.d.ts → types-C2Yexi8A.d.ts} +103 -13
- package/package.json +2 -2
- package/src/ConversationDocumentsPanel.tsx +342 -24
- package/src/ConversationLocalesProvider.tsx +14 -0
- package/src/FileIcon.test.ts +38 -0
- package/src/FileIcon.tsx +15 -4
- package/src/MediaRenderer.tsx +14 -11
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +15 -1
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/preview/createMockConversationsApi.ts +113 -7
- package/src/preview/index.ts +1 -1
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFixtures.ts +156 -1
- package/src/providers/types.ts +110 -9
- package/src/useWaitingNotifications.ts +74 -29
package/src/providers/types.ts
CHANGED
|
@@ -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
|
|
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(
|
|
24
|
-
|
|
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
|
|
3
|
+
import { conversationsOf } from './lib/paginated'
|
|
4
|
+
import type { ConversationSummary, ListConversationsParams } from './providers/types'
|
|
4
5
|
|
|
5
|
-
|
|
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
|
|
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
|
|
17
|
-
if (!
|
|
61
|
+
const refresh = useCallback(async () => {
|
|
62
|
+
if (!conversationsContext || isPollingRef.current) return
|
|
63
|
+
isPollingRef.current = true
|
|
18
64
|
|
|
19
65
|
try {
|
|
20
|
-
const result =
|
|
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
|
|
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
|
|
29
|
-
if (
|
|
74
|
+
for (const conversation of result) {
|
|
75
|
+
if (conversation.unread <= 0) continue
|
|
30
76
|
|
|
31
|
-
const
|
|
77
|
+
const previousUnread = previousUnreadMap.current.get(conversation.id) ?? 0
|
|
78
|
+
if (conversation.unread <= previousUnread || notifiedIds.current.has(conversation.id)) continue
|
|
32
79
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
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
|
-
}, [
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
const interval = setInterval(pollConversations, 10_000)
|
|
101
|
+
void refresh()
|
|
102
|
+
const interval = setInterval(() => void refresh(), intervalMs)
|
|
58
103
|
return () => clearInterval(interval)
|
|
59
|
-
}, [
|
|
104
|
+
}, [refresh, isEnabled, intervalMs])
|
|
60
105
|
|
|
61
|
-
const unreadCount = conversations.reduce((sum,
|
|
106
|
+
const unreadCount = conversations.reduce((sum, conversation) => sum + conversation.unread, 0)
|
|
62
107
|
|
|
63
|
-
return { unreadCount, conversations }
|
|
108
|
+
return { unreadCount, conversations, refresh }
|
|
64
109
|
}
|