@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.
@@ -1,16 +1,14 @@
1
1
  import { useConversations } from '../providers/ConversationsProvider'
2
2
  import { useAsyncResource } from './useAsyncResource'
3
- import type { ConversationSummary } from '../providers/types'
3
+ import { conversationsOf, totalOf } from '../lib/paginated'
4
+ import type { ConversationSummary, ListConversationsParams } from '../providers/types'
4
5
 
5
- export interface UseConversationListParams {
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
- return { conversations: data ?? [], loading, error, refetch }
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: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
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: string; languageCode?: string; bodyParams?: string[] }) => {
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
- export { formatTimestamp, formatFileSize } from './lib/format'
108
+ // `formatDateTime` e `isSameDay` 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 { ConversationDocument, ConversationsApi, ConversationSummary } from '../providers/types'
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
- export function createMockConversationsApi(params: CreateMockConversationsApiParams): ConversationsApi {
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
- fetchConversations(fetchParams): Promise<ConversationSummary[]> {
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 conversations.slice((page - 1) * limit, page * limit)
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
- content: `[template] ${data.templateName}`,
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
- getDocuments(): Promise<ConversationDocument[]> {
100
- return withLatency(() => [])
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
  }
@@ -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
- expect(waiting.every((conversation) => conversation.waitingHuman)).toBe(true)
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
  }