@adatechnology/conversations-ui 0.1.0-rc.4 → 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-4R6Y43DQ.js → chunk-YWITIIHD.js} +18 -16
- package/dist/index.d.ts +102 -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/FileIcon.test.ts +38 -0
- package/src/FileIcon.tsx +15 -4
- package/src/MediaRenderer.tsx +5 -2
- 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
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { useConversations } from '../providers/ConversationsProvider'
|
|
2
2
|
import { useAsyncResource } from './useAsyncResource'
|
|
3
|
-
import
|
|
3
|
+
import { conversationsOf, totalOf } from '../lib/paginated'
|
|
4
|
+
import type { ConversationSummary, ListConversationsParams } from '../providers/types'
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
page?: number
|
|
7
|
-
limit?: number
|
|
8
|
-
waitingHuman?: boolean
|
|
9
|
-
search?: string
|
|
10
|
-
}
|
|
6
|
+
export type UseConversationListParams = ListConversationsParams
|
|
11
7
|
|
|
12
8
|
export interface UseConversationListResult {
|
|
13
9
|
conversations: ConversationSummary[]
|
|
10
|
+
/** Total no servidor. Cai para o tamanho da página quando a API devolve só o array. */
|
|
11
|
+
total: number
|
|
14
12
|
loading: boolean
|
|
15
13
|
error: Error | undefined
|
|
16
14
|
refetch: () => Promise<void>
|
|
@@ -23,10 +21,18 @@ export function useConversationList(params?: UseConversationListParams): UseConv
|
|
|
23
21
|
}
|
|
24
22
|
const { api } = context
|
|
25
23
|
|
|
24
|
+
// `filters` é objeto novo a cada render do host; serializar evita refetch em laço sem obrigar
|
|
25
|
+
// o consumidor a memoizar — omissão que só apareceria como loop de rede em produção.
|
|
26
|
+
const filtersKey = JSON.stringify(params?.filters ?? {})
|
|
27
|
+
|
|
26
28
|
const { data, loading, error, refetch } = useAsyncResource(
|
|
27
29
|
() => api.fetchConversations(params),
|
|
28
|
-
[params?.page, params?.limit, params?.waitingHuman, params?.search],
|
|
30
|
+
[params?.page, params?.limit, params?.waitingHuman, params?.search, filtersKey],
|
|
29
31
|
)
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
if (data === undefined) {
|
|
34
|
+
return { conversations: [], total: 0, loading, error, refetch }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { conversations: conversationsOf(data), total: totalOf(data), loading, error, refetch }
|
|
32
38
|
}
|
|
@@ -10,7 +10,7 @@ export interface UseConversationMessagesResult {
|
|
|
10
10
|
refetch: () => Promise<void>
|
|
11
11
|
sendMessage: (text: string) => Promise<MessagePayload>
|
|
12
12
|
sendMedia: (data: { base64: string; mimeType: string; filename: string; caption?: string }) => Promise<MessagePayload>
|
|
13
|
-
sendTemplate: (data: { templateName
|
|
13
|
+
sendTemplate: (data: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
|
|
14
14
|
markRead: () => Promise<void>
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -51,7 +51,7 @@ export function useConversationMessages(
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
const sendTemplate = useCallback(
|
|
54
|
-
async (templateData: { templateName
|
|
54
|
+
async (templateData: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => {
|
|
55
55
|
await api.sendTemplate(conversationId, templateData)
|
|
56
56
|
await refetch()
|
|
57
57
|
},
|
package/src/index.ts
CHANGED
|
@@ -74,6 +74,11 @@ export type { BuildTranscriptTextParams } from './conversationTranscript'
|
|
|
74
74
|
export { useDarkMode, useIsDarkTheme } from './useDarkMode'
|
|
75
75
|
export { useIsNarrow, NARROW_MAX_WIDTH_PX } from './useIsNarrow'
|
|
76
76
|
export { useWaitingNotifications } from './useWaitingNotifications'
|
|
77
|
+
export type {
|
|
78
|
+
UseWaitingNotificationsLabels,
|
|
79
|
+
UseWaitingNotificationsParams,
|
|
80
|
+
UseWaitingNotificationsResult,
|
|
81
|
+
} from './useWaitingNotifications'
|
|
77
82
|
|
|
78
83
|
export { ConversationsProvider, useConversations } from './providers/ConversationsProvider'
|
|
79
84
|
|
|
@@ -96,10 +101,13 @@ export { useConversationList } from './hooks/useConversationList'
|
|
|
96
101
|
export { useConversationContext } from './hooks/useConversationContext'
|
|
97
102
|
export { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
98
103
|
export { useConversationRealtime, useGlobalRealtime } from './hooks/useConversationRealtime'
|
|
104
|
+
export { useConversationActions, useInboxActions } from './hooks/useConversationActions'
|
|
99
105
|
|
|
100
106
|
export { parseWhatsAppFormatting, waToHTML, htmlToWA, waToHTMLInline } from './lib/whatsapp-formatting'
|
|
101
107
|
export { formatPhone, phoneInitials } from './lib/phone'
|
|
102
|
-
|
|
108
|
+
// `formatDateTime` e `isSameDay` já eram usados pelas bolhas e pelo divisor de data; exportá-los
|
|
109
|
+
// evita que cada host mantenha a própria cópia e acabe com timeline e transcript divergindo.
|
|
110
|
+
export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './lib/format'
|
|
103
111
|
|
|
104
112
|
export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
|
|
105
113
|
export type {
|
|
@@ -108,7 +116,13 @@ export type {
|
|
|
108
116
|
ConversationEventSource,
|
|
109
117
|
ConversationSummary,
|
|
110
118
|
ConversationDocument,
|
|
119
|
+
ConversationPage,
|
|
120
|
+
ConversationDocumentPage,
|
|
121
|
+
ConversationTemplate,
|
|
122
|
+
ListConversationsParams,
|
|
123
|
+
ListDocumentsParams,
|
|
111
124
|
} from './providers/types'
|
|
125
|
+
export type { UseConversationActionsResult, UseInboxActionsResult } from './hooks/useConversationActions'
|
|
112
126
|
|
|
113
127
|
export type { MessageBubbleProps } from './MessageBubble'
|
|
114
128
|
export type { ConversationWallpaperProps } from './Wallpaper'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { cn } from './cn'
|
|
4
|
+
|
|
5
|
+
describe('cn', () => {
|
|
6
|
+
// O contrato de `classNames`/`className` dos componentes depende disto: se o override não vencer,
|
|
7
|
+
// a API de customização é decorativa. Concatenar falharia justo no caso comum — o Tailwind emite
|
|
8
|
+
// `px-2` antes de `px-4`, então a base ganharia de quem quer apertar o espaçamento.
|
|
9
|
+
it('o override do host vence a classe base em conflito', () => {
|
|
10
|
+
expect(cn('px-4 py-3', 'px-2')).toBe('py-3 px-2')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('mantém o que não conflita', () => {
|
|
14
|
+
expect(cn('flex items-center gap-3', 'gap-1')).toBe('flex items-center gap-1')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('preserva classes próprias do pacote, que o merge não conhece', () => {
|
|
18
|
+
expect(cn('cv-row flex', 'bg-red-50')).toBe('cv-row flex bg-red-50')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('ignora ausente, falso e vazio', () => {
|
|
22
|
+
expect(cn('border-b', undefined, false, '')).toBe('border-b')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('aplica condicional na ordem recebida', () => {
|
|
26
|
+
const isCompact = false
|
|
27
|
+
expect(cn('mt-2', isCompact && 'mt-8', 'mt-4')).toBe('mt-4')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { conversationsOf, documentsOf, totalOf } from './paginated'
|
|
3
|
+
import type { ConversationDocument, ConversationSummary } from '../providers/types'
|
|
4
|
+
|
|
5
|
+
const conversation = { id: '1', whatsappNumber: '5511900000000' } as ConversationSummary
|
|
6
|
+
const document = { id: 'd1', filename: 'contrato.pdf' } as ConversationDocument
|
|
7
|
+
|
|
8
|
+
describe('normalização de lista ou página', () => {
|
|
9
|
+
// As duas formas existem porque o contrato aceita as duas: array puro é o retorno original, e
|
|
10
|
+
// implementações antigas continuam válidas. Um consumidor que só tratasse uma delas quebraria
|
|
11
|
+
// em runtime, não no compilador.
|
|
12
|
+
it('aceita o array puro do contrato original', () => {
|
|
13
|
+
expect(conversationsOf([conversation])).toEqual([conversation])
|
|
14
|
+
expect(documentsOf([document])).toEqual([document])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('desembrulha a forma paginada', () => {
|
|
18
|
+
expect(conversationsOf({ conversations: [conversation], total: 42 })).toEqual([conversation])
|
|
19
|
+
expect(documentsOf({ documents: [document], total: 7 })).toEqual([document])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('usa o total do servidor quando ele vem, e o tamanho da página quando não vem', () => {
|
|
23
|
+
// A distinção importa: com array puro não dá para saber se a página é a última, então o
|
|
24
|
+
// melhor palpite honesto é o que se tem em mãos.
|
|
25
|
+
expect(totalOf({ conversations: [conversation], total: 42 })).toBe(42)
|
|
26
|
+
expect(totalOf([conversation])).toBe(1)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('trata página vazia sem confundir com ausência de dados', () => {
|
|
30
|
+
expect(conversationsOf({ conversations: [], total: 0 })).toEqual([])
|
|
31
|
+
expect(totalOf({ conversations: [], total: 0 })).toBe(0)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConversationDocument,
|
|
3
|
+
ConversationDocumentPage,
|
|
4
|
+
ConversationPage,
|
|
5
|
+
ConversationSummary,
|
|
6
|
+
} from '../providers/types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `fetchConversations` e `getDocuments` devolvem o array puro (contrato original) ou uma página
|
|
10
|
+
* com total. Normalizar num lugar só evita que cada consumidor invente sua própria checagem —
|
|
11
|
+
* e um consumidor esquecido não falha no compilador, falha em runtime com `.map is not a
|
|
12
|
+
* function`.
|
|
13
|
+
*/
|
|
14
|
+
export function conversationsOf(result: ConversationSummary[] | ConversationPage): ConversationSummary[] {
|
|
15
|
+
return Array.isArray(result) ? result : result.conversations
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function documentsOf(result: ConversationDocument[] | ConversationDocumentPage): ConversationDocument[] {
|
|
19
|
+
return Array.isArray(result) ? result : result.documents
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function totalOf(
|
|
23
|
+
result: ConversationSummary[] | ConversationPage | ConversationDocument[] | ConversationDocumentPage,
|
|
24
|
+
): number {
|
|
25
|
+
return Array.isArray(result) ? result.length : result.total
|
|
26
|
+
}
|
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { MessagePayload } from '../types'
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
ConversationDocumentPage,
|
|
13
|
+
ConversationPage,
|
|
14
|
+
ConversationTemplate,
|
|
15
|
+
ConversationsApi,
|
|
16
|
+
ListConversationsParams,
|
|
17
|
+
} from '../providers/types'
|
|
18
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
12
19
|
import type { PreviewStore } from './previewStore'
|
|
13
20
|
|
|
14
21
|
// PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
|
|
@@ -23,7 +30,24 @@ export type CreateMockConversationsApiParams = {
|
|
|
23
30
|
|
|
24
31
|
const DEFAULT_LATENCY_MS = 120
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
const PREVIEW_AGENT_ID = 'preview-agent'
|
|
34
|
+
|
|
35
|
+
const PREVIEW_TEMPLATES: readonly ConversationTemplate[] = [
|
|
36
|
+
{ name: 'retomada_atendimento', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
37
|
+
{ name: 'lembrete_documentos', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
38
|
+
{ name: 'promocao_taxa', language: 'pt_BR', status: 'PENDING', category: 'MARKETING' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* O mock satisfaz `ConversationsApi`, mas com o retorno de `fetchConversations` ESTREITADO para a
|
|
43
|
+
* forma paginada. Sem isto o contrato — que aceita array ou página — obrigaria todo consumidor do
|
|
44
|
+
* preview a desempacotar uma união que aqui nunca varia.
|
|
45
|
+
*/
|
|
46
|
+
export type MockConversationsApi = Omit<ConversationsApi, 'fetchConversations'> & {
|
|
47
|
+
fetchConversations(params?: ListConversationsParams): Promise<ConversationPage>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createMockConversationsApi(params: CreateMockConversationsApiParams): MockConversationsApi {
|
|
27
51
|
const latencyMs = params.latencyMs ?? DEFAULT_LATENCY_MS
|
|
28
52
|
|
|
29
53
|
async function withLatency<TResult>(produce: () => TResult): Promise<TResult> {
|
|
@@ -32,7 +56,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
return {
|
|
35
|
-
|
|
59
|
+
// Devolve a forma paginada, não o array puro: é a que o contrato passou a oferecer e a que
|
|
60
|
+
// permite o preview desenhar controles de página. O total é contado ANTES do corte — depois
|
|
61
|
+
// dele seria sempre o tamanho da página, e a paginação nunca sairia da primeira.
|
|
62
|
+
fetchConversations(fetchParams): Promise<ConversationPage> {
|
|
36
63
|
return withLatency(() => {
|
|
37
64
|
const conversations = params.store.listConversations({
|
|
38
65
|
waitingHuman: fetchParams?.waitingHuman,
|
|
@@ -41,7 +68,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
41
68
|
|
|
42
69
|
const limit = fetchParams?.limit ?? conversations.length
|
|
43
70
|
const page = fetchParams?.page ?? 1
|
|
44
|
-
return
|
|
71
|
+
return {
|
|
72
|
+
conversations: conversations.slice((page - 1) * limit, page * limit),
|
|
73
|
+
total: conversations.length,
|
|
74
|
+
}
|
|
45
75
|
})
|
|
46
76
|
},
|
|
47
77
|
|
|
@@ -74,7 +104,9 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
74
104
|
return withLatency(() => {
|
|
75
105
|
params.store.appendMessage({
|
|
76
106
|
conversationId,
|
|
77
|
-
|
|
107
|
+
// Sem nome, o host está pedindo o template padrão do backend — o mock representa isso
|
|
108
|
+
// pelo que o atendente veria, não por um nome inventado.
|
|
109
|
+
content: `[template] ${data.templateName ?? PREVIEW_TEMPLATES[0]?.name ?? 'padrao'}`,
|
|
78
110
|
direction: 'outbound',
|
|
79
111
|
sender: 'agent',
|
|
80
112
|
})
|
|
@@ -96,8 +128,54 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
96
128
|
})
|
|
97
129
|
},
|
|
98
130
|
|
|
99
|
-
|
|
100
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Espelha o backend em busca, filtro de origem, ordenação E paginação. Mock que ignora params
|
|
133
|
+
* faz o painel parecer quebrado aqui e, pior, esconde o caso em que o backend também os ignora
|
|
134
|
+
* — foi exatamente assim que o filtro de origem passou a existir só no contrato.
|
|
135
|
+
*/
|
|
136
|
+
getDocuments(conversationId, documentParams): Promise<ConversationDocumentPage> {
|
|
137
|
+
return withLatency(() => {
|
|
138
|
+
let documents = [...(PREVIEW_DOCUMENTS[conversationId] ?? [])]
|
|
139
|
+
|
|
140
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
141
|
+
if (search) {
|
|
142
|
+
documents = documents.filter((document) => document.filename.toLowerCase().includes(search))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 'team' agrupa agent + bot, como o painel apresenta.
|
|
146
|
+
const source = documentParams?.source
|
|
147
|
+
if (source === 'team') {
|
|
148
|
+
documents = documents.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
149
|
+
} else if (source) {
|
|
150
|
+
documents = documents.filter((document) => document.source === source)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
documents.sort((left, right) =>
|
|
154
|
+
documentParams?.sortDirection === 'asc'
|
|
155
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
156
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// Total contado ANTES do corte — depois seria sempre o tamanho da página, e a paginação
|
|
160
|
+
// nunca sairia da primeira.
|
|
161
|
+
const total = documents.length
|
|
162
|
+
const limit = documentParams?.limit ?? total
|
|
163
|
+
const page = documentParams?.page ?? 1
|
|
164
|
+
|
|
165
|
+
return { documents: documents.slice((page - 1) * limit, page * limit), total }
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Zip de mentira: um texto listando o que entraria. Basta para exercitar seleção, botão e o
|
|
171
|
+
* caminho de download no preview, sem arrastar uma lib de compactação para o pacote.
|
|
172
|
+
*/
|
|
173
|
+
downloadDocumentsArchive(conversationId, uploadIds): Promise<Blob> {
|
|
174
|
+
return withLatency(() => {
|
|
175
|
+
const known = PREVIEW_DOCUMENTS[conversationId] ?? []
|
|
176
|
+
const names = uploadIds.map((id) => known.find((document) => document.id === id)?.filename ?? id)
|
|
177
|
+
return new Blob([`preview: ${names.length} arquivo(s)\n${names.join('\n')}`], { type: 'application/zip' })
|
|
178
|
+
})
|
|
101
179
|
},
|
|
102
180
|
|
|
103
181
|
getDocumentUrl(): Promise<string> {
|
|
@@ -107,5 +185,33 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
107
185
|
getMediaProxyUrl(): Promise<{ mimeType: string; data: string }> {
|
|
108
186
|
return withLatency(() => ({ mimeType: 'image/png', data: PREVIEW_IMAGE_BASE64 }))
|
|
109
187
|
},
|
|
188
|
+
|
|
189
|
+
takeover(conversationId): Promise<void> {
|
|
190
|
+
return withLatency(() =>
|
|
191
|
+
params.store.setMode({ conversationId, mode: 'human', assignedUserId: PREVIEW_AGENT_ID }),
|
|
192
|
+
)
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
release(conversationId): Promise<void> {
|
|
196
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Encerrar devolve ao bot como o release, e é de propósito: a diferença entre os dois é a
|
|
200
|
+
// despedida, que o host manda antes de chamar aqui. O mock não a inventa.
|
|
201
|
+
finalize(conversationId): Promise<void> {
|
|
202
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
markAllRead(): Promise<void> {
|
|
206
|
+
return withLatency(() => {
|
|
207
|
+
for (const conversation of params.store.listConversations()) {
|
|
208
|
+
params.store.markRead(conversation.id)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
listTemplates(): Promise<ConversationTemplate[]> {
|
|
214
|
+
return withLatency(() => [...PREVIEW_TEMPLATES])
|
|
215
|
+
},
|
|
110
216
|
}
|
|
111
217
|
}
|
package/src/preview/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type { CreateMockConversationsApiParams } from './createMockConversations
|
|
|
23
23
|
export { createMockSSEProvider } from './createMockSSEProvider'
|
|
24
24
|
export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
25
25
|
|
|
26
|
-
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
26
|
+
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
27
|
|
|
28
28
|
export { ConversationPreview } from './ConversationPreview'
|
|
29
29
|
export type { ConversationPreviewProps } from './ConversationPreview'
|
|
@@ -145,8 +145,10 @@ describe('createMockConversationsApi', () => {
|
|
|
145
145
|
const firstPage = await api.fetchConversations({ page: 1, limit: 2 })
|
|
146
146
|
const waiting = await api.fetchConversations({ waitingHuman: true })
|
|
147
147
|
|
|
148
|
-
expect(firstPage).toHaveLength(2)
|
|
149
|
-
|
|
148
|
+
expect(firstPage.conversations).toHaveLength(2)
|
|
149
|
+
// O total conta o conjunto filtrado inteiro, não a fatia — é o que a paginação da UI lê.
|
|
150
|
+
expect(firstPage.total).toBeGreaterThan(2)
|
|
151
|
+
expect(waiting.conversations.every((conversation) => conversation.waitingHuman)).toBe(true)
|
|
150
152
|
})
|
|
151
153
|
|
|
152
154
|
it('markRead zera as não-lidas vistas pela lista', async () => {
|
|
@@ -156,7 +158,7 @@ describe('createMockConversationsApi', () => {
|
|
|
156
158
|
await api.markRead(BOT_CONVERSATION_ID)
|
|
157
159
|
const conversations = await api.fetchConversations()
|
|
158
160
|
|
|
159
|
-
expect(conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
161
|
+
expect(conversations.conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
160
162
|
})
|
|
161
163
|
})
|
|
162
164
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { MessagePayload } from '../types'
|
|
8
|
-
import type { ConversationSummary } from '../providers/types'
|
|
8
|
+
import type { ConversationDocument, ConversationSummary } from '../providers/types'
|
|
9
9
|
|
|
10
10
|
// Datas fixas: fixture com data relativa ao relógio faz o mesmo cenário renderizar diferente a
|
|
11
11
|
// cada execução, e separadores de dia deixam de ser verificáveis.
|
|
@@ -71,6 +71,24 @@ export const PREVIEW_CONVERSATIONS: readonly ConversationSummary[] = [
|
|
|
71
71
|
unread: 1,
|
|
72
72
|
currentState: 'list_import',
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
// Cobre TODO tipo que o composer aceita (DEFAULT_ACCEPTED_FILE_TYPES: image/*, video/*,
|
|
76
|
+
// audio/*, .pdf, .doc, .docx, .xls, .xlsx, .zip) mais sticker. Existe para que cada ramo do
|
|
77
|
+
// MediaRenderer e cada ícone/cor do FileIcon apareçam em algum lugar — ramo sem fixture é ramo
|
|
78
|
+
// que ninguém olha até quebrar em produção.
|
|
79
|
+
id: '5511944443333',
|
|
80
|
+
whatsappNumber: '5511944443333',
|
|
81
|
+
clientName: 'Rita Documentos',
|
|
82
|
+
lastContent: 'segue a planilha do pedido',
|
|
83
|
+
lastDirection: 'inbound',
|
|
84
|
+
lastAt: at('15:10:00'),
|
|
85
|
+
lastInboundAt: at('15:10:00'),
|
|
86
|
+
mode: 'human',
|
|
87
|
+
assignedUserId: 'agent-1',
|
|
88
|
+
waitingHuman: false,
|
|
89
|
+
unread: 3,
|
|
90
|
+
currentState: 'human_handling',
|
|
91
|
+
},
|
|
74
92
|
]
|
|
75
93
|
|
|
76
94
|
export const PREVIEW_MESSAGES: Readonly<Record<string, readonly MessagePayload[]>> = {
|
|
@@ -150,4 +168,141 @@ export const PREVIEW_MESSAGES: Readonly<Record<string, readonly MessagePayload[]
|
|
|
150
168
|
timestamp: at('13:31:00'),
|
|
151
169
|
},
|
|
152
170
|
],
|
|
171
|
+
// Um tipo por mensagem, na ordem em que o MediaRenderer os trata.
|
|
172
|
+
'5511944443333': [
|
|
173
|
+
{
|
|
174
|
+
id: 'fixture-doc-image',
|
|
175
|
+
type: 'image',
|
|
176
|
+
mediaId: 'preview-image-1',
|
|
177
|
+
mimeType: 'image/png',
|
|
178
|
+
caption: 'foto da prateleira',
|
|
179
|
+
direction: 'inbound',
|
|
180
|
+
sender: 'customer',
|
|
181
|
+
timestamp: at('15:00:00'),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'fixture-doc-video',
|
|
185
|
+
type: 'video',
|
|
186
|
+
mediaId: 'preview-video-1',
|
|
187
|
+
mimeType: 'video/mp4',
|
|
188
|
+
direction: 'inbound',
|
|
189
|
+
sender: 'customer',
|
|
190
|
+
timestamp: at('15:01:00'),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'fixture-doc-audio',
|
|
194
|
+
type: 'audio',
|
|
195
|
+
mediaId: 'preview-audio-2',
|
|
196
|
+
mimeType: 'audio/ogg',
|
|
197
|
+
direction: 'inbound',
|
|
198
|
+
sender: 'customer',
|
|
199
|
+
timestamp: at('15:02:00'),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'fixture-doc-sticker',
|
|
203
|
+
type: 'sticker',
|
|
204
|
+
mediaId: 'preview-sticker-1',
|
|
205
|
+
mimeType: 'image/webp',
|
|
206
|
+
direction: 'inbound',
|
|
207
|
+
sender: 'customer',
|
|
208
|
+
timestamp: at('15:03:00'),
|
|
209
|
+
},
|
|
210
|
+
// Os cinco ramos do FileIcon: pdf, doc, xls, zip e o genérico do fallback.
|
|
211
|
+
{
|
|
212
|
+
id: 'fixture-doc-pdf',
|
|
213
|
+
type: 'document',
|
|
214
|
+
uploadId: 'preview/documentos/nota-fiscal.pdf',
|
|
215
|
+
filename: 'nota-fiscal.pdf',
|
|
216
|
+
mimeType: 'application/pdf',
|
|
217
|
+
sizeBytes: 184_320,
|
|
218
|
+
direction: 'inbound',
|
|
219
|
+
sender: 'customer',
|
|
220
|
+
timestamp: at('15:04:00'),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 'fixture-doc-docx',
|
|
224
|
+
type: 'document',
|
|
225
|
+
uploadId: 'preview/documentos/contrato.docx',
|
|
226
|
+
filename: 'contrato.docx',
|
|
227
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
228
|
+
sizeBytes: 42_112,
|
|
229
|
+
direction: 'inbound',
|
|
230
|
+
sender: 'customer',
|
|
231
|
+
timestamp: at('15:05:00'),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'fixture-doc-doc',
|
|
235
|
+
type: 'document',
|
|
236
|
+
uploadId: 'preview/documentos/procuracao.doc',
|
|
237
|
+
filename: 'procuracao.doc',
|
|
238
|
+
mimeType: 'application/msword',
|
|
239
|
+
sizeBytes: 31_744,
|
|
240
|
+
direction: 'inbound',
|
|
241
|
+
sender: 'customer',
|
|
242
|
+
timestamp: at('15:06:00'),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: 'fixture-doc-xlsx',
|
|
246
|
+
type: 'document',
|
|
247
|
+
uploadId: 'preview/documentos/pedido.xlsx',
|
|
248
|
+
filename: 'pedido.xlsx',
|
|
249
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
250
|
+
sizeBytes: 15_872,
|
|
251
|
+
direction: 'inbound',
|
|
252
|
+
sender: 'customer',
|
|
253
|
+
timestamp: at('15:07:00'),
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: 'fixture-doc-xls',
|
|
257
|
+
type: 'document',
|
|
258
|
+
uploadId: 'preview/documentos/tabela-antiga.xls',
|
|
259
|
+
filename: 'tabela-antiga.xls',
|
|
260
|
+
mimeType: 'application/vnd.ms-excel',
|
|
261
|
+
sizeBytes: 9_216,
|
|
262
|
+
direction: 'inbound',
|
|
263
|
+
sender: 'customer',
|
|
264
|
+
timestamp: at('15:08:00'),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: 'fixture-doc-zip',
|
|
268
|
+
type: 'document',
|
|
269
|
+
uploadId: 'preview/documentos/comprovantes.zip',
|
|
270
|
+
filename: 'comprovantes.zip',
|
|
271
|
+
mimeType: 'application/zip',
|
|
272
|
+
sizeBytes: 2_355_200,
|
|
273
|
+
direction: 'inbound',
|
|
274
|
+
sender: 'customer',
|
|
275
|
+
timestamp: at('15:09:00'),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
// Extensão fora do EXTENSION_STYLE: garante que o ícone genérico cinza também apareça.
|
|
279
|
+
id: 'fixture-doc-generic',
|
|
280
|
+
type: 'document',
|
|
281
|
+
uploadId: 'preview/documentos/lista-compras.txt',
|
|
282
|
+
filename: 'lista-compras.txt',
|
|
283
|
+
mimeType: 'text/plain',
|
|
284
|
+
sizeBytes: 1_024,
|
|
285
|
+
direction: 'inbound',
|
|
286
|
+
sender: 'customer',
|
|
287
|
+
timestamp: at('15:10:00'),
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A biblioteca de arquivos da conversa, como o backend a devolveria. Deriva das mensagens de
|
|
294
|
+
* documento acima em vez de repetir os dados: fixture duplicada divergiria na primeira edição, e o
|
|
295
|
+
* painel passaria a mostrar arquivo que a thread não tem.
|
|
296
|
+
*/
|
|
297
|
+
export const PREVIEW_DOCUMENTS: Readonly<Record<string, readonly ConversationDocument[]>> = {
|
|
298
|
+
'5511944443333': (PREVIEW_MESSAGES['5511944443333'] ?? [])
|
|
299
|
+
.filter((message) => message.type === 'document')
|
|
300
|
+
.map((message) => ({
|
|
301
|
+
id: message.uploadId ?? message.id,
|
|
302
|
+
filename: message.filename ?? message.id,
|
|
303
|
+
mimeType: message.mimeType ?? 'application/octet-stream',
|
|
304
|
+
sizeBytes: message.sizeBytes ?? 0,
|
|
305
|
+
source: message.sender,
|
|
306
|
+
linkedAt: message.timestamp,
|
|
307
|
+
})),
|
|
153
308
|
}
|