@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41

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.
Files changed (149) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2389 -678
  7. package/dist/index.d.ts +1171 -42
  8. package/dist/index.js +3755 -843
  9. package/dist/preview/index.d.ts +157 -42
  10. package/dist/preview/index.js +772 -191
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +382 -0
  29. package/src/EmojiPicker.tsx +70 -55
  30. package/src/FileIcon.test.ts +83 -0
  31. package/src/FileIcon.tsx +88 -11
  32. package/src/InteractiveMessage.test.tsx +41 -0
  33. package/src/InteractiveMessage.tsx +146 -0
  34. package/src/Lightbox.tsx +18 -3
  35. package/src/MediaRenderer.tsx +92 -16
  36. package/src/MessageBubble.test.tsx +41 -0
  37. package/src/MessageBubble.tsx +75 -6
  38. package/src/MessageComposer.test.tsx +35 -0
  39. package/src/MessageComposer.tsx +155 -19
  40. package/src/RichMessageComposer.test.tsx +113 -0
  41. package/src/RichMessageComposer.tsx +551 -0
  42. package/src/SimpleEmojiPicker.tsx +5 -3
  43. package/src/StatusTicks.tsx +1 -1
  44. package/src/Toast.tsx +4 -0
  45. package/src/Tooltip.test.ts +42 -0
  46. package/src/Tooltip.tsx +167 -0
  47. package/src/Wallpaper.test.tsx +21 -0
  48. package/src/Wallpaper.tsx +67 -7
  49. package/src/WhatsAppMessageEditor.tsx +34 -7
  50. package/src/WindowExpiredNotice.tsx +12 -4
  51. package/src/audioRecorderFormat.test.ts +67 -0
  52. package/src/buildOutput.test.ts +79 -0
  53. package/src/composer.constant.ts +33 -0
  54. package/src/conversationTranscript.test.ts +57 -0
  55. package/src/conversationTranscript.ts +29 -4
  56. package/src/conversationWindow.ts +7 -5
  57. package/src/documentTypeLabel.test.ts +57 -0
  58. package/src/documents/DocumentsWorkspace.tsx +550 -0
  59. package/src/documents/index.ts +8 -0
  60. package/src/documents/labels.ts +92 -0
  61. package/src/emojiCatalog.test.ts +35 -0
  62. package/src/emojiCatalog.ts +189 -0
  63. package/src/flows/FlowConnectionEdge.tsx +104 -0
  64. package/src/flows/FlowGroupHeader.tsx +12 -2
  65. package/src/flows/FlowLegend.tsx +125 -0
  66. package/src/flows/FlowMapCanvas.tsx +15 -12
  67. package/src/flows/FlowMapNode.tsx +4 -1
  68. package/src/flows/FlowNodeCard.tsx +219 -34
  69. package/src/flows/FlowNodePanel.tsx +153 -39
  70. package/src/flows/FlowPalette.tsx +106 -69
  71. package/src/flows/FlowPortalNode.tsx +1 -1
  72. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  73. package/src/flows/FlowsWorkspace.tsx +1219 -0
  74. package/src/flows/flowCanvasModel.test.ts +456 -0
  75. package/src/flows/flowCanvasModel.ts +378 -0
  76. package/src/flows/flowEditorOps.test.ts +276 -0
  77. package/src/flows/flowEditorOps.ts +202 -0
  78. package/src/flows/flowGraph.ts +78 -53
  79. package/src/flows/index.ts +51 -2
  80. package/src/flows/labels.ts +180 -0
  81. package/src/flows/workspaceContract.test.ts +126 -0
  82. package/src/hooks/useContainerWidth.ts +35 -0
  83. package/src/hooks/useConversationActions.ts +56 -0
  84. package/src/hooks/useConversationDocuments.ts +11 -7
  85. package/src/hooks/useConversationList.ts +15 -9
  86. package/src/hooks/useConversationMessages.ts +2 -2
  87. package/src/hooks/useConversationRealtime.ts +10 -8
  88. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  89. package/src/hooks/useUrlFilterState.ts +107 -0
  90. package/src/icon.constant.ts +12 -0
  91. package/src/index.ts +129 -13
  92. package/src/lib/cn.test.ts +29 -0
  93. package/src/lib/composer-formatting.test.ts +78 -0
  94. package/src/lib/composer-formatting.ts +145 -0
  95. package/src/lib/createMediaUrlResolver.ts +33 -0
  96. package/src/lib/paginated.test.ts +33 -0
  97. package/src/lib/paginated.ts +26 -0
  98. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  99. package/src/lib/whatsapp-formatting.tsx +28 -3
  100. package/src/listing/index.tsx +202 -0
  101. package/src/pagination.constant.ts +10 -0
  102. package/src/preview/ConversationPreview.tsx +225 -17
  103. package/src/preview/ConversationSimulatorClient.ts +143 -0
  104. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  105. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  106. package/src/preview/MediaTypesPreview.tsx +87 -0
  107. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  108. package/src/preview/createMockConversationsApi.ts +175 -15
  109. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  110. package/src/preview/createPreviewBridgeClient.ts +124 -0
  111. package/src/preview/createPreviewMediaUploader.ts +82 -0
  112. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  113. package/src/preview/createPreviewWebhookClient.ts +127 -4
  114. package/src/preview/index.ts +51 -3
  115. package/src/preview/mediaTypeOf.test.ts +15 -0
  116. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  117. package/src/preview/preview.test.ts +5 -3
  118. package/src/preview/previewFileSamples.test.ts +151 -0
  119. package/src/preview/previewFileSamples.ts +74 -0
  120. package/src/preview/previewFixtures.ts +288 -1
  121. package/src/preview/previewMediaSource.test.ts +62 -0
  122. package/src/preview/previewMediaSource.ts +91 -0
  123. package/src/preview/previewMediaUploader.test.ts +61 -0
  124. package/src/providers/ConversationsProvider.tsx +8 -6
  125. package/src/providers/types.ts +185 -10
  126. package/src/quickReply.test.ts +58 -0
  127. package/src/settings/MessagesWorkspace.tsx +571 -0
  128. package/src/settings/TopicsForm.tsx +2 -0
  129. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  130. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  131. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  132. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  133. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  134. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  135. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  136. package/src/styles.css +858 -0
  137. package/src/types.ts +64 -1
  138. package/src/useWaitingNotifications.ts +74 -29
  139. package/src/workspace/BulkTemplateModal.tsx +132 -0
  140. package/src/workspace/ConversationPane.tsx +432 -0
  141. package/src/workspace/ConversationsInboxList.tsx +194 -0
  142. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  143. package/src/workspace/index.ts +17 -0
  144. package/src/workspace/labels.test.ts +17 -0
  145. package/src/workspace/labels.ts +85 -0
  146. package/src/workspace/useConversationsInbox.ts +332 -0
  147. package/dist/chunk-4R6Y43DQ.js +0 -726
  148. package/dist/chunk-NV2RZ5KT.js +0 -56
  149. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -8,49 +8,121 @@
8
8
  */
9
9
 
10
10
  import { useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, Users } from 'lucide-react'
11
12
  import { useConversationDocuments } from './hooks/useConversationDocuments'
12
13
  import { useConversations } from './providers/ConversationsProvider'
13
14
  import { FileIcon } from './FileIcon'
14
15
  import { cn } from './lib/cn'
15
- import { formatFileSize, formatTimestamp } from './lib/format'
16
+ import { PAGINATION_LABELS } from './pagination.constant'
17
+ import { formatDateTime, formatFileSize } from './lib/format'
18
+
19
+ /** Origens que o filtro oferece. `all` não é origem — é a ausência de filtro. */
20
+ export const DOCUMENT_SOURCE_FILTER = {
21
+ ALL: 'all',
22
+ CUSTOMER: 'customer',
23
+ TEAM: 'team',
24
+ } as const
25
+ export type DocumentSourceFilter = (typeof DOCUMENT_SOURCE_FILTER)[keyof typeof DOCUMENT_SOURCE_FILTER]
26
+
27
+ /**
28
+ * `bot` e `agent` caem em "Equipe": para quem procura um comprovante, o que importa é se veio do
29
+ * cliente ou saiu da loja — não se foi o robô ou a pessoa que apertou enviar.
30
+ */
31
+ const TEAM_SOURCES = new Set(['agent', 'bot'])
16
32
 
17
33
  export interface ConversationDocumentsPanelLabels {
18
34
  toggle: string
19
35
  title: string
20
36
  searchPlaceholder: string
21
37
  empty: string
38
+ /** Distinto de `empty`: sem resultado POR CAUSA do filtro, e não conversa sem anexo nenhum. */
39
+ noResults: string
22
40
  loading: string
23
41
  failure: string
24
42
  download: string
43
+ view: string
44
+ sourceFilterAll: string
45
+ sourceFilterCustomer: string
46
+ sourceFilterTeam: string
47
+ sortMostRecent: string
48
+ sortOldest: string
49
+ clearFilters: string
50
+ selectAll: string
51
+ downloadSelected: (count: number) => string
52
+ archiveFailed: string
53
+ total: (count: number) => string
54
+ page: (current: number, last: number) => string
25
55
  }
26
56
 
27
57
  export const DEFAULT_CONVERSATION_DOCUMENTS_LABELS: ConversationDocumentsPanelLabels = {
28
58
  toggle: '📎 Arquivos',
29
59
  title: 'Arquivos da conversa',
30
- searchPlaceholder: 'Buscar arquivo...',
60
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
61
+ view: 'Visualizar',
62
+ sourceFilterAll: 'Todas as origens',
63
+ sourceFilterCustomer: 'Cliente',
64
+ sourceFilterTeam: 'Equipe',
65
+ sortMostRecent: 'Mais recentes',
66
+ sortOldest: 'Mais antigos',
67
+ clearFilters: 'Limpar filtros',
68
+ selectAll: 'Selecionar todos desta página',
69
+ downloadSelected: (count: number) => `Baixar ${count} selecionado${count === 1 ? '' : 's'} (.zip)`,
70
+ archiveFailed: 'Não foi possível montar o arquivo compactado.',
71
+ total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
72
+ page: (current: number, last: number) => `${current} / ${last}`,
73
+ searchPlaceholder: 'Buscar por nome do arquivo',
31
74
  empty: 'Nenhum arquivo nesta conversa.',
32
75
  loading: 'Carregando arquivos…',
33
76
  failure: 'Não foi possível carregar os arquivos.',
34
77
  download: 'Baixar',
35
78
  }
36
79
 
80
+ /**
81
+ * Partes estilizáveis do painel, no mesmo contrato do `ConversationHeader`: `cn` funde por cima da
82
+ * base e conflito de utilitário (padding, tamanho de fonte, borda) fica com o valor do produto.
83
+ *
84
+ * `status` cobre carregando/erro/vazio de uma vez — são a mesma linha de texto auxiliar, e slots
85
+ * separados só multiplicariam chave para quem quer mudar a cor de aviso.
86
+ */
37
87
  export interface ConversationDocumentsPanelClassNames {
38
88
  root: string
39
89
  body: string
90
+ title: string
91
+ filters: string
92
+ search: string
93
+ sourceSelect: string
94
+ sortButton: string
95
+ clearButton: string
96
+ status: string
97
+ list: string
98
+ item: string
99
+ sourceBadge: string
100
+ filename: string
101
+ meta: string
102
+ viewButton: string
103
+ downloadButton: string
104
+ pagination: string
105
+ selectionBar: string
106
+ checkbox: string
40
107
  }
41
108
 
42
109
  export interface ConversationDocumentsPanelProps {
43
110
  conversationId: string
44
111
  /** Controlado de fora porque o gatilho vive no cabeçalho, junto das outras ações da conversa. */
45
112
  open: boolean
113
+ /** Itens por página. O total vem do servidor; sem paginação no host, a barra não aparece. */
114
+ perPage?: number
46
115
  labels?: Partial<ConversationDocumentsPanelLabels>
47
116
  className?: string
48
117
  classNames?: Partial<ConversationDocumentsPanelClassNames>
49
118
  }
50
119
 
120
+ const DEFAULT_PER_PAGE = 10
121
+
51
122
  export function ConversationDocumentsPanel({
52
123
  conversationId,
53
124
  open,
125
+ perPage = DEFAULT_PER_PAGE,
54
126
  labels: labelsOverride,
55
127
  className,
56
128
  classNames,
@@ -58,49 +130,300 @@ export function ConversationDocumentsPanel({
58
130
  const labels = { ...DEFAULT_CONVERSATION_DOCUMENTS_LABELS, ...labelsOverride }
59
131
  const context = useConversations()
60
132
  const [search, setSearch] = useState('')
133
+ const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
134
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
135
+ const [page, setPage] = useState(1)
136
+ const [selectedIds, setSelectedIds] = useState<readonly string[]>([])
137
+ const [archiveError, setArchiveError] = useState(false)
138
+
139
+ const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
61
140
 
62
141
  // Só busca quando o painel abre: a lista de anexos é consulta extra e não deve pesar em toda
63
142
  // conversa aberta.
64
- const { documents, loading, error } = useConversationDocuments(open ? conversationId : undefined, { search })
143
+ const { documents, total, loading, error } = useConversationDocuments(open ? conversationId : undefined, {
144
+ search,
145
+ page,
146
+ limit: perPage,
147
+ sortDirection,
148
+ ...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
149
+ })
65
150
 
66
- async function handleDownload(uploadId: string): Promise<void> {
67
- const url = await context?.api.getDocumentUrl(uploadId)
151
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
152
+
153
+ /**
154
+ * Toda mudança de filtro volta para a página 1. Sem isso, filtrar na página 3 pede ao servidor
155
+ * uma fatia que o novo resultado talvez não tenha, e o painel aparece vazio como se não houvesse
156
+ * arquivo — o mesmo motivo do `setDocumentsPage(1)` em cada handler do financiamento.
157
+ */
158
+ function applyFilter(change: () => void): void {
159
+ change()
160
+ setPage(1)
161
+ }
162
+
163
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
164
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
68
165
  if (url) window.open(url, '_blank', 'noopener,noreferrer')
69
166
  }
70
167
 
168
+ // Ausente = host não sabe montar zip (só assina URL). Esconder é melhor que oferecer e falhar.
169
+ const canArchive = typeof context?.api.downloadDocumentsArchive === 'function'
170
+ const pageIds = documents.map((document) => document.id)
171
+ const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.includes(id))
172
+
173
+ function toggleSelected(uploadId: string): void {
174
+ setArchiveError(false)
175
+ setSelectedIds((current) =>
176
+ current.includes(uploadId) ? current.filter((id) => id !== uploadId) : [...current, uploadId],
177
+ )
178
+ }
179
+
180
+ function toggleAllOnPage(): void {
181
+ setArchiveError(false)
182
+ // Só mexe nos itens DESTA página: quem selecionou algo na página 1, foi para a 2 e marcou
183
+ // "todos" não deve perder a seleção anterior.
184
+ setSelectedIds((current) =>
185
+ allOnPageSelected
186
+ ? current.filter((id) => !pageIds.includes(id))
187
+ : [...current, ...pageIds.filter((id) => !current.includes(id))],
188
+ )
189
+ }
190
+
191
+ async function handleDownloadSelected(): Promise<void> {
192
+ const archive = context?.api.downloadDocumentsArchive
193
+ if (!archive || selectedIds.length === 0) return
194
+
195
+ setArchiveError(false)
196
+ try {
197
+ const blob = await archive(conversationId, selectedIds)
198
+ // Âncora temporária com objectURL: é o que faz o navegador salvar um Blob com nome. O
199
+ // revoke é obrigatório — sem ele o blob fica na memória da aba até recarregar.
200
+ const url = URL.createObjectURL(blob)
201
+ const anchor = document.createElement('a')
202
+ anchor.href = url
203
+ anchor.download = `conversa-${conversationId}.zip`
204
+ anchor.click()
205
+ URL.revokeObjectURL(url)
206
+ setSelectedIds([])
207
+ } catch {
208
+ setArchiveError(true)
209
+ }
210
+ }
211
+
71
212
  if (!open) return null
72
213
 
73
214
  return (
74
215
  <div className={cn('border-b', classNames?.root, className)}>
75
216
  <section className={cn('px-4 py-3', classNames?.body)}>
76
- <p className="mb-2 text-sm font-medium">{labels.title}</p>
217
+ <p className={cn('mb-2 text-sm font-medium', classNames?.title)}>{labels.title}</p>
218
+
219
+ {/* Barra de filtros com wrap: em tela estreita os quatro controles empilham em vez de
220
+ estourar a largura do painel. */}
221
+ <div className={cn('mb-2 flex flex-wrap items-center gap-2 border-b pb-2', classNames?.filters)}>
77
222
  <input
78
223
  type="search"
79
224
  value={search}
80
- onChange={(event) => setSearch(event.target.value)}
225
+ onChange={(event) => applyFilter(() => setSearch(event.target.value))}
81
226
  placeholder={labels.searchPlaceholder}
82
- className="mb-2 w-full rounded-md border px-3 py-2 text-sm"
227
+ aria-label={labels.searchPlaceholder}
228
+ className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-52', classNames?.search)}
83
229
  />
84
230
 
85
- {loading ? <p className="text-xs text-gray-500">{labels.loading}</p> : null}
86
- {error ? <p className="text-xs text-red-600 dark:text-red-400">{labels.failure}</p> : null}
87
- {!loading && !error && documents.length === 0 ? (
88
- <p className="text-xs text-gray-500">{labels.empty}</p>
231
+ <select
232
+ value={sourceFilter}
233
+ onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
234
+ aria-label={labels.sourceFilterAll}
235
+ className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-36', classNames?.sourceSelect)}
236
+ >
237
+ <option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
238
+ <option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
239
+ <option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
240
+ </select>
241
+
242
+ <button
243
+ data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
244
+ type="button"
245
+ onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
246
+ className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
247
+ >
248
+ <ArrowUpDown size={14} />
249
+ {sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
250
+ </button>
251
+
252
+ {/* Só aparece com filtro ativo: botão que não faz nada visível é ruído. */}
253
+ {hasFilters ? (
254
+ <button
255
+ data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
256
+ type="button"
257
+ onClick={() =>
258
+ applyFilter(() => {
259
+ setSearch('')
260
+ setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
261
+ setSortDirection('desc')
262
+ })
263
+ }
264
+ className={cn('cv-header-action', classNames?.clearButton)}
265
+ >
266
+ {labels.clearFilters}
267
+ </button>
89
268
  ) : null}
269
+ </div>
270
+
271
+ {loading ? <p className={cn('text-xs text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
272
+ {error ? (
273
+ <p role="alert" className={cn('text-xs text-red-600 dark:text-red-400', classNames?.status)}>
274
+ {labels.failure}
275
+ </p>
276
+ ) : null}
277
+ {!loading && !error && documents.length === 0 ? (
278
+ <p className={cn('text-xs text-gray-500', classNames?.status)}>
279
+ {/* Distinguir os dois evita o mal-entendido de "a conversa não tem anexo" quando o
280
+ filtro é que não casou. */}
281
+ {hasFilters ? labels.noResults : labels.empty}
282
+ </p>
283
+ ) : null}
284
+
285
+ {canArchive && documents.length > 0 ? (
286
+ <div className={cn('mb-2 flex flex-wrap items-center gap-3 text-xs', classNames?.selectionBar)}>
287
+ <label className="inline-flex items-center gap-1.5">
288
+ <input
289
+ type="checkbox"
290
+ checked={allOnPageSelected}
291
+ onChange={toggleAllOnPage}
292
+ className={cn(classNames?.checkbox)}
293
+ />
294
+ {labels.selectAll}
295
+ </label>
296
+ {selectedIds.length > 0 ? (
297
+ <button data-cv-tooltip={labels.downloadSelected(selectedIds.length)} aria-label={labels.downloadSelected(selectedIds.length)} type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
298
+ {labels.downloadSelected(selectedIds.length)}
299
+ </button>
300
+ ) : null}
301
+ {archiveError ? (
302
+ <span role="alert" className="text-red-600 dark:text-red-400">
303
+ {labels.archiveFailed}
304
+ </span>
305
+ ) : null}
306
+ </div>
307
+ ) : null}
90
308
 
91
- <ul className="space-y-1">
92
- {documents.map((document) => (
93
- <li key={document.id} className="flex items-center gap-2 text-xs">
94
- <FileIcon mimeType={document.mimeType} />
95
- <span className="min-w-0 flex-1 truncate">{document.filename}</span>
96
- <span className="text-gray-500">{formatFileSize(document.sizeBytes)}</span>
97
- <span className="text-gray-500">{formatTimestamp(document.linkedAt)}</span>
98
- <button type="button" onClick={() => void handleDownload(document.id)} className="cv-header-action">
99
- {labels.download}
100
- </button>
309
+ <ul className={cn('space-y-2', classNames?.list)}>
310
+ {documents.map((document) => {
311
+ const isFromCustomer = !TEAM_SOURCES.has(document.source)
312
+ const SourceIcon = isFromCustomer ? Users : Bot
313
+
314
+ return (
315
+ <li
316
+ key={document.id}
317
+ className={cn(
318
+ 'flex items-center justify-between gap-2 rounded-lg border px-3 py-2 dark:border-gray-700',
319
+ classNames?.item,
320
+ )}
321
+ >
322
+ <div className="flex min-w-0 flex-1 items-center gap-2">
323
+ {canArchive ? (
324
+ <input
325
+ type="checkbox"
326
+ checked={selectedIds.includes(document.id)}
327
+ onChange={() => toggleSelected(document.id)}
328
+ aria-label={`${labels.download}: ${document.filename}`}
329
+ className={cn('shrink-0', classNames?.checkbox)}
330
+ />
331
+ ) : null}
332
+ {/* `filename` junto do mimeType, e não só o mimeType: o mapa de ícones é indexado
333
+ por extensão curta, e o mimeType do Office é longo
334
+ (`…wordprocessingml.document`), então docx/doc/xlsx/xls caíam todos no ícone
335
+ genérico cinza. Com o nome, a extensão resolve primeiro. */}
336
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
337
+ <div className="min-w-0 flex-1">
338
+ <div
339
+ className={cn(
340
+ 'mb-0.5 flex items-center gap-1 text-[11px] font-medium',
341
+ isFromCustomer
342
+ ? 'text-blue-600 dark:text-blue-400'
343
+ : 'text-emerald-600 dark:text-emerald-400',
344
+ classNames?.sourceBadge,
345
+ )}
346
+ >
347
+ <SourceIcon size={11} />
348
+ {isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
349
+ </div>
350
+ {/* `title` no nome: truncado, o atendente não tem outro jeito de ler inteiro. */}
351
+ <div
352
+ className={cn('truncate text-sm font-medium', classNames?.filename)}
353
+ data-cv-tooltip={document.filename}
354
+ >
355
+ {document.filename}
356
+ </div>
357
+ <div className={cn('text-xs text-gray-500 dark:text-gray-400', classNames?.meta)}>
358
+ {formatDateTime(document.linkedAt)}
359
+ {' · '}
360
+ {formatFileSize(document.sizeBytes)}
361
+ </div>
362
+ </div>
363
+ </div>
364
+
365
+ {/* Ver e baixar são ações distintas, não a mesma com nome diferente: o
366
+ `disposition` viaja na assinatura da URL, e depois de assinada não há como o
367
+ cliente mudar entre abrir no navegador e salvar. */}
368
+ <div className="flex shrink-0 gap-1">
369
+ <button
370
+ type="button"
371
+ onClick={() => void handleOpen(document.id, 'inline')}
372
+ data-cv-tooltip={labels.view}
373
+ aria-label={`${labels.view}: ${document.filename}`}
374
+ className={cn('cv-header-icon', classNames?.viewButton)}
375
+ >
376
+ <Eye size={14} />
377
+ </button>
378
+ <button
379
+ type="button"
380
+ onClick={() => void handleOpen(document.id, 'attachment')}
381
+ data-cv-tooltip={labels.download}
382
+ aria-label={`${labels.download}: ${document.filename}`}
383
+ className={cn('cv-header-icon', classNames?.downloadButton)}
384
+ >
385
+ <Download size={14} />
386
+ </button>
387
+ </div>
101
388
  </li>
102
- ))}
103
- </ul>
389
+ )
390
+ })}
391
+ </ul>
392
+
393
+ {/* Só com mais de uma página: barra de paginação numa lista de três anexos é ruído. */}
394
+ {total > perPage ? (
395
+ <div
396
+ className={cn(
397
+ 'mt-2 flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700',
398
+ classNames?.pagination,
399
+ )}
400
+ >
401
+ <span className="text-gray-400">{labels.total(total)}</span>
402
+ <div className="flex items-center gap-2">
403
+ <button
404
+ type="button"
405
+ onClick={() => setPage(page - 1)}
406
+ disabled={page <= 1}
407
+ aria-label={PAGINATION_LABELS.previous}
408
+ data-cv-tooltip={PAGINATION_LABELS.previous}
409
+ className="cv-header-icon disabled:opacity-40"
410
+ >
411
+ ‹
412
+ </button>
413
+ <span className="text-gray-500">{labels.page(page, lastPage)}</span>
414
+ <button
415
+ type="button"
416
+ onClick={() => setPage(page + 1)}
417
+ disabled={page >= lastPage}
418
+ aria-label={PAGINATION_LABELS.next}
419
+ data-cv-tooltip={PAGINATION_LABELS.next}
420
+ className="cv-header-icon disabled:opacity-40"
421
+ >
422
+ ›
423
+ </button>
424
+ </div>
425
+ </div>
426
+ ) : null}
104
427
  </section>
105
428
  </div>
106
429
  )
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ConversationHeader } from './ConversationHeader'
5
+ import type { ConversationSummary } from './providers/types'
6
+
7
+ const noop = () => {}
8
+
9
+ const CONVERSATION: ConversationSummary = {
10
+ id: 'conv-1',
11
+ clientName: 'Anderson Silva',
12
+ whatsappNumber: '5511988887777',
13
+ channel: 'whatsapp',
14
+ mode: 'human',
15
+ assignedUserId: null,
16
+ waitingHuman: false,
17
+ unread: 0,
18
+ currentState: 'idle',
19
+ lastAt: '2026-01-01T12:00:00.000Z',
20
+ lastInboundAt: null,
21
+ }
22
+
23
+ describe('ConversationHeader sem medida da faixa', () => {
24
+ // Sem `ResizeObserver` (SSR e este teste de markup) o cabeçalho nasce completo. O recolhimento é
25
+ // medido no próprio elemento, e é verificado no navegador.
26
+ it('mostra utilitários e ações na faixa, sem duplicar no menu', () => {
27
+ const markup = renderToStaticMarkup(
28
+ <ConversationHeader
29
+ conversation={CONVERSATION}
30
+ onDownload={noop}
31
+ onOpenDocuments={noop}
32
+ onReturnToBot={noop}
33
+ onFinish={noop}
34
+ />,
35
+ )
36
+
37
+ expect(markup).toContain('aria-label="Baixar conversa"')
38
+ expect(markup).toContain('aria-label="Finalizar"')
39
+ expect(markup).not.toContain('aria-label="Mais ações"')
40
+ })
41
+
42
+ it('escreve o canal e o modo por extenso quando há espaço', () => {
43
+ const markup = renderToStaticMarkup(<ConversationHeader conversation={CONVERSATION} />)
44
+
45
+ expect(markup).toContain('atendimento humano')
46
+ })
47
+
48
+ // Emoji desenha diferente em cada sistema e não herda a cor do controle: os ícones do cabeçalho
49
+ // são SVG da biblioteca, como no resto do produto.
50
+ it('desenha os ícones como SVG, sem emoji no meio dos rótulos', () => {
51
+ const markup = renderToStaticMarkup(
52
+ <ConversationHeader
53
+ conversation={CONVERSATION}
54
+ onBack={noop}
55
+ onDownload={noop}
56
+ onOpenDocuments={noop}
57
+ onReturnToBot={noop}
58
+ onFinish={noop}
59
+ />,
60
+ )
61
+
62
+ expect(markup).toContain('lucide-download')
63
+ expect(markup).not.toContain('✕')
64
+ expect(markup).not.toContain('⬇️')
65
+ })
66
+ })