@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.20

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 (99) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-OIDAIVCH.js +1985 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +729 -136
  6. package/dist/index.js +1418 -1104
  7. package/dist/preview/index.d.ts +373 -0
  8. package/dist/preview/index.js +1247 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-O7kMP1Yn.d.ts +416 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/Avatar.tsx +30 -4
  15. package/src/ChannelIcon.tsx +87 -0
  16. package/src/ConversationContextPanel.tsx +127 -0
  17. package/src/ConversationDocumentsPanel.tsx +425 -0
  18. package/src/ConversationHeader.tsx +257 -0
  19. package/src/ConversationListItem.tsx +54 -7
  20. package/src/ConversationLocalesProvider.tsx +16 -0
  21. package/src/ConversationRow.tsx +137 -0
  22. package/src/DateDivider.tsx +16 -3
  23. package/src/DocumentsLibrary.tsx +322 -0
  24. package/src/EmojiPicker.tsx +69 -55
  25. package/src/FileIcon.test.ts +83 -0
  26. package/src/FileIcon.tsx +88 -11
  27. package/src/InteractiveMessage.test.tsx +41 -0
  28. package/src/InteractiveMessage.tsx +143 -0
  29. package/src/Lightbox.tsx +18 -3
  30. package/src/MediaRenderer.tsx +64 -21
  31. package/src/MessageBubble.tsx +54 -5
  32. package/src/MessageComposer.test.tsx +35 -0
  33. package/src/MessageComposer.tsx +146 -19
  34. package/src/RichMessageComposer.test.tsx +83 -0
  35. package/src/RichMessageComposer.tsx +380 -0
  36. package/src/Wallpaper.test.tsx +21 -0
  37. package/src/Wallpaper.tsx +54 -6
  38. package/src/WhatsAppMessageEditor.tsx +28 -4
  39. package/src/WindowExpiredNotice.tsx +57 -0
  40. package/src/audioRecorderFormat.test.ts +67 -0
  41. package/src/conversationChannel.test.ts +53 -0
  42. package/src/conversationChannel.ts +146 -0
  43. package/src/conversationTranscript.test.ts +65 -0
  44. package/src/conversationTranscript.ts +64 -0
  45. package/src/conversationWindow.test.ts +90 -0
  46. package/src/conversationWindow.ts +78 -0
  47. package/src/documentTypeLabel.test.ts +57 -0
  48. package/src/emojiCatalog.test.ts +35 -0
  49. package/src/emojiCatalog.ts +189 -0
  50. package/src/flows/FlowGroupHeader.tsx +12 -2
  51. package/src/flows/FlowMapCanvas.tsx +17 -14
  52. package/src/flows/FlowMapNode.tsx +3 -1
  53. package/src/flows/FlowNodeCard.tsx +22 -4
  54. package/src/flows/FlowNodePanel.tsx +132 -35
  55. package/src/flows/FlowPalette.tsx +6 -2
  56. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  57. package/src/flows/flowGraph.ts +5 -5
  58. package/src/flows/labels.ts +5 -0
  59. package/src/hooks/useConversationActions.ts +56 -0
  60. package/src/hooks/useConversationDocuments.ts +15 -9
  61. package/src/hooks/useConversationList.ts +15 -9
  62. package/src/hooks/useConversationMessages.ts +2 -2
  63. package/src/index.ts +129 -16
  64. package/src/lib/cn.test.ts +29 -0
  65. package/src/lib/cn.ts +15 -0
  66. package/src/lib/createMediaUrlResolver.ts +33 -0
  67. package/src/lib/paginated.test.ts +33 -0
  68. package/src/lib/paginated.ts +26 -0
  69. package/src/lib/phone.ts +34 -0
  70. package/src/preview/ConversationPreview.tsx +325 -0
  71. package/src/preview/MediaTypesPreview.tsx +87 -0
  72. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  73. package/src/preview/createMockConversationsApi.ts +271 -0
  74. package/src/preview/createMockSSEProvider.ts +40 -0
  75. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  76. package/src/preview/createPreviewBridgeClient.ts +88 -0
  77. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  78. package/src/preview/createPreviewWebhookClient.ts +131 -0
  79. package/src/preview/index.ts +61 -0
  80. package/src/preview/mediaTypeOf.test.ts +15 -0
  81. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  82. package/src/preview/mockEventSource.ts +53 -0
  83. package/src/preview/preview.test.ts +177 -0
  84. package/src/preview/previewFileSamples.test.ts +151 -0
  85. package/src/preview/previewFileSamples.ts +74 -0
  86. package/src/preview/previewFixtures.ts +440 -0
  87. package/src/preview/previewMediaSource.test.ts +62 -0
  88. package/src/preview/previewMediaSource.ts +91 -0
  89. package/src/preview/previewStore.ts +193 -0
  90. package/src/preview/startPreviewScript.ts +60 -0
  91. package/src/providers/types.ts +163 -11
  92. package/src/quickReply.test.ts +58 -0
  93. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  94. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  95. package/src/styles.css +136 -0
  96. package/src/types.ts +46 -1
  97. package/src/useDarkMode.ts +26 -0
  98. package/src/useIsNarrow.ts +29 -0
  99. package/src/useWaitingNotifications.ts +74 -29
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Arquivos trocados na conversa. Sai do transcript e vira lista própria porque anexo é o que o
3
+ * atendente mais precisa reencontrar depois — rolar meses de mensagens para achar um comprovante é
4
+ * o caso que a busca por documento existe para eliminar.
5
+ *
6
+ * Usa `useConversationDocuments`, então funciona com qualquer `ConversationsApi`. Host sem
7
+ * biblioteca de documentos cai no estado vazio, sem quebrar.
8
+ */
9
+
10
+ import { useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, Users } from 'lucide-react'
12
+ import { useConversationDocuments } from './hooks/useConversationDocuments'
13
+ import { useConversations } from './providers/ConversationsProvider'
14
+ import { FileIcon } from './FileIcon'
15
+ import { cn } from './lib/cn'
16
+ import { formatDateTime, formatFileSize } from './lib/format'
17
+
18
+ /** Origens que o filtro oferece. `all` não é origem — é a ausência de filtro. */
19
+ export const DOCUMENT_SOURCE_FILTER = {
20
+ ALL: 'all',
21
+ CUSTOMER: 'customer',
22
+ TEAM: 'team',
23
+ } as const
24
+ export type DocumentSourceFilter = (typeof DOCUMENT_SOURCE_FILTER)[keyof typeof DOCUMENT_SOURCE_FILTER]
25
+
26
+ /**
27
+ * `bot` e `agent` caem em "Equipe": para quem procura um comprovante, o que importa é se veio do
28
+ * cliente ou saiu da loja — não se foi o robô ou a pessoa que apertou enviar.
29
+ */
30
+ const TEAM_SOURCES = new Set(['agent', 'bot'])
31
+
32
+ export interface ConversationDocumentsPanelLabels {
33
+ toggle: string
34
+ title: string
35
+ searchPlaceholder: string
36
+ empty: string
37
+ /** Distinto de `empty`: sem resultado POR CAUSA do filtro, e não conversa sem anexo nenhum. */
38
+ noResults: string
39
+ loading: string
40
+ failure: string
41
+ download: string
42
+ view: string
43
+ sourceFilterAll: string
44
+ sourceFilterCustomer: string
45
+ sourceFilterTeam: string
46
+ sortMostRecent: string
47
+ sortOldest: string
48
+ clearFilters: string
49
+ selectAll: string
50
+ downloadSelected: (count: number) => string
51
+ archiveFailed: string
52
+ total: (count: number) => string
53
+ page: (current: number, last: number) => string
54
+ }
55
+
56
+ export const DEFAULT_CONVERSATION_DOCUMENTS_LABELS: ConversationDocumentsPanelLabels = {
57
+ toggle: '📎 Arquivos',
58
+ title: 'Arquivos da conversa',
59
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
60
+ view: 'Visualizar',
61
+ sourceFilterAll: 'Todas as origens',
62
+ sourceFilterCustomer: 'Cliente',
63
+ sourceFilterTeam: 'Equipe',
64
+ sortMostRecent: 'Mais recentes',
65
+ sortOldest: 'Mais antigos',
66
+ clearFilters: 'Limpar filtros',
67
+ selectAll: 'Selecionar todos desta página',
68
+ downloadSelected: (count: number) => `Baixar ${count} selecionado${count === 1 ? '' : 's'} (.zip)`,
69
+ archiveFailed: 'Não foi possível montar o arquivo compactado.',
70
+ total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
71
+ page: (current: number, last: number) => `${current} / ${last}`,
72
+ searchPlaceholder: 'Buscar por nome do arquivo',
73
+ empty: 'Nenhum arquivo nesta conversa.',
74
+ loading: 'Carregando arquivos…',
75
+ failure: 'Não foi possível carregar os arquivos.',
76
+ download: 'Baixar',
77
+ }
78
+
79
+ /**
80
+ * Partes estilizáveis do painel, no mesmo contrato do `ConversationHeader`: `cn` funde por cima da
81
+ * base e conflito de utilitário (padding, tamanho de fonte, borda) fica com o valor do produto.
82
+ *
83
+ * `status` cobre carregando/erro/vazio de uma vez — são a mesma linha de texto auxiliar, e slots
84
+ * separados só multiplicariam chave para quem quer mudar a cor de aviso.
85
+ */
86
+ export interface ConversationDocumentsPanelClassNames {
87
+ root: string
88
+ body: string
89
+ title: string
90
+ filters: string
91
+ search: string
92
+ sourceSelect: string
93
+ sortButton: string
94
+ clearButton: string
95
+ status: string
96
+ list: string
97
+ item: string
98
+ sourceBadge: string
99
+ filename: string
100
+ meta: string
101
+ viewButton: string
102
+ downloadButton: string
103
+ pagination: string
104
+ selectionBar: string
105
+ checkbox: string
106
+ }
107
+
108
+ export interface ConversationDocumentsPanelProps {
109
+ conversationId: string
110
+ /** Controlado de fora porque o gatilho vive no cabeçalho, junto das outras ações da conversa. */
111
+ open: boolean
112
+ /** Itens por página. O total vem do servidor; sem paginação no host, a barra não aparece. */
113
+ perPage?: number
114
+ labels?: Partial<ConversationDocumentsPanelLabels>
115
+ className?: string
116
+ classNames?: Partial<ConversationDocumentsPanelClassNames>
117
+ }
118
+
119
+ const DEFAULT_PER_PAGE = 10
120
+
121
+ export function ConversationDocumentsPanel({
122
+ conversationId,
123
+ open,
124
+ perPage = DEFAULT_PER_PAGE,
125
+ labels: labelsOverride,
126
+ className,
127
+ classNames,
128
+ }: ConversationDocumentsPanelProps) {
129
+ const labels = { ...DEFAULT_CONVERSATION_DOCUMENTS_LABELS, ...labelsOverride }
130
+ const context = useConversations()
131
+ const [search, setSearch] = useState('')
132
+ const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
133
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
134
+ const [page, setPage] = useState(1)
135
+ const [selectedIds, setSelectedIds] = useState<readonly string[]>([])
136
+ const [archiveError, setArchiveError] = useState(false)
137
+
138
+ const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
139
+
140
+ // Só busca quando o painel abre: a lista de anexos é consulta extra e não deve pesar em toda
141
+ // conversa aberta.
142
+ const { documents, total, loading, error } = useConversationDocuments(open ? conversationId : undefined, {
143
+ search,
144
+ page,
145
+ limit: perPage,
146
+ sortDirection,
147
+ ...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
148
+ })
149
+
150
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
151
+
152
+ /**
153
+ * Toda mudança de filtro volta para a página 1. Sem isso, filtrar na página 3 pede ao servidor
154
+ * uma fatia que o novo resultado talvez não tenha, e o painel aparece vazio como se não houvesse
155
+ * arquivo — o mesmo motivo do `setDocumentsPage(1)` em cada handler do financiamento.
156
+ */
157
+ function applyFilter(change: () => void): void {
158
+ change()
159
+ setPage(1)
160
+ }
161
+
162
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
163
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
164
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
165
+ }
166
+
167
+ // Ausente = host não sabe montar zip (só assina URL). Esconder é melhor que oferecer e falhar.
168
+ const canArchive = typeof context?.api.downloadDocumentsArchive === 'function'
169
+ const pageIds = documents.map((document) => document.id)
170
+ const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.includes(id))
171
+
172
+ function toggleSelected(uploadId: string): void {
173
+ setArchiveError(false)
174
+ setSelectedIds((current) =>
175
+ current.includes(uploadId) ? current.filter((id) => id !== uploadId) : [...current, uploadId],
176
+ )
177
+ }
178
+
179
+ function toggleAllOnPage(): void {
180
+ setArchiveError(false)
181
+ // Só mexe nos itens DESTA página: quem selecionou algo na página 1, foi para a 2 e marcou
182
+ // "todos" não deve perder a seleção anterior.
183
+ setSelectedIds((current) =>
184
+ allOnPageSelected
185
+ ? current.filter((id) => !pageIds.includes(id))
186
+ : [...current, ...pageIds.filter((id) => !current.includes(id))],
187
+ )
188
+ }
189
+
190
+ async function handleDownloadSelected(): Promise<void> {
191
+ const archive = context?.api.downloadDocumentsArchive
192
+ if (!archive || selectedIds.length === 0) return
193
+
194
+ setArchiveError(false)
195
+ try {
196
+ const blob = await archive(conversationId, selectedIds)
197
+ // Âncora temporária com objectURL: é o que faz o navegador salvar um Blob com nome. O
198
+ // revoke é obrigatório — sem ele o blob fica na memória da aba até recarregar.
199
+ const url = URL.createObjectURL(blob)
200
+ const anchor = document.createElement('a')
201
+ anchor.href = url
202
+ anchor.download = `conversa-${conversationId}.zip`
203
+ anchor.click()
204
+ URL.revokeObjectURL(url)
205
+ setSelectedIds([])
206
+ } catch {
207
+ setArchiveError(true)
208
+ }
209
+ }
210
+
211
+ if (!open) return null
212
+
213
+ return (
214
+ <div className={cn('border-b', classNames?.root, className)}>
215
+ <section className={cn('px-4 py-3', classNames?.body)}>
216
+ <p className={cn('mb-2 text-sm font-medium', classNames?.title)}>{labels.title}</p>
217
+
218
+ {/* Barra de filtros com wrap: em tela estreita os quatro controles empilham em vez de
219
+ estourar a largura do painel. */}
220
+ <div className={cn('mb-2 flex flex-wrap items-center gap-2 border-b pb-2', classNames?.filters)}>
221
+ <input
222
+ type="search"
223
+ value={search}
224
+ onChange={(event) => applyFilter(() => setSearch(event.target.value))}
225
+ placeholder={labels.searchPlaceholder}
226
+ aria-label={labels.searchPlaceholder}
227
+ className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-52', classNames?.search)}
228
+ />
229
+
230
+ <select
231
+ value={sourceFilter}
232
+ onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
233
+ aria-label={labels.sourceFilterAll}
234
+ className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-36', classNames?.sourceSelect)}
235
+ >
236
+ <option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
237
+ <option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
238
+ <option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
239
+ </select>
240
+
241
+ <button
242
+ type="button"
243
+ onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
244
+ className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
245
+ >
246
+ <ArrowUpDown size={14} />
247
+ {sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
248
+ </button>
249
+
250
+ {/* Só aparece com filtro ativo: botão que não faz nada visível é ruído. */}
251
+ {hasFilters ? (
252
+ <button
253
+ type="button"
254
+ onClick={() =>
255
+ applyFilter(() => {
256
+ setSearch('')
257
+ setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
258
+ setSortDirection('desc')
259
+ })
260
+ }
261
+ className={cn('cv-header-action', classNames?.clearButton)}
262
+ >
263
+ {labels.clearFilters}
264
+ </button>
265
+ ) : null}
266
+ </div>
267
+
268
+ {loading ? <p className={cn('text-xs text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
269
+ {error ? (
270
+ <p role="alert" className={cn('text-xs text-red-600 dark:text-red-400', classNames?.status)}>
271
+ {labels.failure}
272
+ </p>
273
+ ) : null}
274
+ {!loading && !error && documents.length === 0 ? (
275
+ <p className={cn('text-xs text-gray-500', classNames?.status)}>
276
+ {/* Distinguir os dois evita o mal-entendido de "a conversa não tem anexo" quando o
277
+ filtro é que não casou. */}
278
+ {hasFilters ? labels.noResults : labels.empty}
279
+ </p>
280
+ ) : null}
281
+
282
+ {canArchive && documents.length > 0 ? (
283
+ <div className={cn('mb-2 flex flex-wrap items-center gap-3 text-xs', classNames?.selectionBar)}>
284
+ <label className="inline-flex items-center gap-1.5">
285
+ <input
286
+ type="checkbox"
287
+ checked={allOnPageSelected}
288
+ onChange={toggleAllOnPage}
289
+ className={cn(classNames?.checkbox)}
290
+ />
291
+ {labels.selectAll}
292
+ </label>
293
+ {selectedIds.length > 0 ? (
294
+ <button type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
295
+ {labels.downloadSelected(selectedIds.length)}
296
+ </button>
297
+ ) : null}
298
+ {archiveError ? (
299
+ <span role="alert" className="text-red-600 dark:text-red-400">
300
+ {labels.archiveFailed}
301
+ </span>
302
+ ) : null}
303
+ </div>
304
+ ) : null}
305
+
306
+ <ul className={cn('space-y-2', classNames?.list)}>
307
+ {documents.map((document) => {
308
+ const isFromCustomer = !TEAM_SOURCES.has(document.source)
309
+ const SourceIcon = isFromCustomer ? Users : Bot
310
+
311
+ return (
312
+ <li
313
+ key={document.id}
314
+ className={cn(
315
+ 'flex items-center justify-between gap-2 rounded-lg border px-3 py-2 dark:border-gray-700',
316
+ classNames?.item,
317
+ )}
318
+ >
319
+ <div className="flex min-w-0 flex-1 items-center gap-2">
320
+ {canArchive ? (
321
+ <input
322
+ type="checkbox"
323
+ checked={selectedIds.includes(document.id)}
324
+ onChange={() => toggleSelected(document.id)}
325
+ aria-label={`${labels.download}: ${document.filename}`}
326
+ className={cn('shrink-0', classNames?.checkbox)}
327
+ />
328
+ ) : null}
329
+ {/* `filename` junto do mimeType, e não só o mimeType: o mapa de ícones é indexado
330
+ por extensão curta, e o mimeType do Office é longo
331
+ (`…wordprocessingml.document`), então docx/doc/xlsx/xls caíam todos no ícone
332
+ genérico cinza. Com o nome, a extensão resolve primeiro. */}
333
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
334
+ <div className="min-w-0 flex-1">
335
+ <div
336
+ className={cn(
337
+ 'mb-0.5 flex items-center gap-1 text-[11px] font-medium',
338
+ isFromCustomer
339
+ ? 'text-blue-600 dark:text-blue-400'
340
+ : 'text-emerald-600 dark:text-emerald-400',
341
+ classNames?.sourceBadge,
342
+ )}
343
+ >
344
+ <SourceIcon size={11} />
345
+ {isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
346
+ </div>
347
+ {/* `title` no nome: truncado, o atendente não tem outro jeito de ler inteiro. */}
348
+ <div
349
+ className={cn('truncate text-sm font-medium', classNames?.filename)}
350
+ title={document.filename}
351
+ >
352
+ {document.filename}
353
+ </div>
354
+ <div className={cn('text-xs text-gray-500 dark:text-gray-400', classNames?.meta)}>
355
+ {formatDateTime(document.linkedAt)}
356
+ {' · '}
357
+ {formatFileSize(document.sizeBytes)}
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ {/* Ver e baixar são ações distintas, não a mesma com nome diferente: o
363
+ `disposition` viaja na assinatura da URL, e depois de assinada não há como o
364
+ cliente mudar entre abrir no navegador e salvar. */}
365
+ <div className="flex shrink-0 gap-1">
366
+ <button
367
+ type="button"
368
+ onClick={() => void handleOpen(document.id, 'inline')}
369
+ title={labels.view}
370
+ aria-label={`${labels.view}: ${document.filename}`}
371
+ className={cn('cv-header-icon', classNames?.viewButton)}
372
+ >
373
+ <Eye size={14} />
374
+ </button>
375
+ <button
376
+ type="button"
377
+ onClick={() => void handleOpen(document.id, 'attachment')}
378
+ title={labels.download}
379
+ aria-label={`${labels.download}: ${document.filename}`}
380
+ className={cn('cv-header-icon', classNames?.downloadButton)}
381
+ >
382
+ <Download size={14} />
383
+ </button>
384
+ </div>
385
+ </li>
386
+ )
387
+ })}
388
+ </ul>
389
+
390
+ {/* Só com mais de uma página: barra de paginação numa lista de três anexos é ruído. */}
391
+ {total > perPage ? (
392
+ <div
393
+ className={cn(
394
+ 'mt-2 flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700',
395
+ classNames?.pagination,
396
+ )}
397
+ >
398
+ <span className="text-gray-400">{labels.total(total)}</span>
399
+ <div className="flex items-center gap-2">
400
+ <button
401
+ type="button"
402
+ onClick={() => setPage(page - 1)}
403
+ disabled={page <= 1}
404
+ aria-label={labels.sortOldest}
405
+ className="cv-header-icon disabled:opacity-40"
406
+ >
407
+ ‹
408
+ </button>
409
+ <span className="text-gray-500">{labels.page(page, lastPage)}</span>
410
+ <button
411
+ type="button"
412
+ onClick={() => setPage(page + 1)}
413
+ disabled={page >= lastPage}
414
+ aria-label={labels.sortMostRecent}
415
+ className="cv-header-icon disabled:opacity-40"
416
+ >
417
+ ›
418
+ </button>
419
+ </div>
420
+ </div>
421
+ ) : null}
422
+ </section>
423
+ </div>
424
+ )
425
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Cabeçalho do painel de conversa: quem é o cliente, quem está conduzindo (bot ou humano) e as
3
+ * ações de atendimento.
4
+ *
5
+ * Presentacional por decisão de arquitetura: `ConversationsApi` não tem takeover/release, e não é
6
+ * papel do pacote saber a rota de cada host. Quem passa os handlers é o produto.
7
+ */
8
+
9
+ import { useState } from 'react'
10
+
11
+ import type { ConversationSummary } from './providers/types'
12
+ import { capabilitiesOf, contactFlag, formatContactHandle } from './conversationChannel'
13
+ import { cn } from './lib/cn'
14
+ import { Avatar } from './Avatar'
15
+ import { ChannelIcon } from './ChannelIcon'
16
+
17
+ export interface ConversationHeaderLabels {
18
+ botMode: string
19
+ humanMode: string
20
+ returnToBot: string
21
+ finish: string
22
+ takeover: string
23
+ download: string
24
+ documents: string
25
+ back: string
26
+ moreActions: string
27
+ }
28
+
29
+ export const DEFAULT_CONVERSATION_HEADER_LABELS: ConversationHeaderLabels = {
30
+ botMode: '🤖 atendimento automático',
31
+ humanMode: '🧑‍💼 atendimento humano',
32
+ returnToBot: 'Devolver ao bot',
33
+ finish: 'Finalizar',
34
+ takeover: 'Assumir atendimento',
35
+ download: 'Baixar conversa',
36
+ documents: 'Arquivos da conversa',
37
+ back: 'Voltar para a lista',
38
+ moreActions: 'Mais ações',
39
+ }
40
+
41
+ /**
42
+ * Partes estilizáveis do cabeçalho. Cada chave recebe classes que o `cn` funde por cima da base, e
43
+ * conflito de utilitário (padding, gap, borda) fica com o valor do produto.
44
+ */
45
+ export interface ConversationHeaderClassNames {
46
+ root: string
47
+ identity: string
48
+ name: string
49
+ meta: string
50
+ actions: string
51
+ desktopActions: string
52
+ mobileMenu: string
53
+ }
54
+
55
+ /**
56
+ * Utilitário extra que o host pendura no cabeçalho — ícone no desktop, item de menu no celular,
57
+ * como os nativos. Entra por aqui, e não por um slot de ReactNode, porque é isso que preserva o
58
+ * comportamento responsivo: um nó solto viraria um quarto ícone em 375px, sem área de toque.
59
+ */
60
+ export interface ConversationHeaderUtility {
61
+ key: string
62
+ /** Emoji, para casar com os utilitários nativos do cabeçalho. */
63
+ icon: string
64
+ label: string
65
+ run: () => void
66
+ active?: boolean
67
+ }
68
+
69
+ export interface ConversationHeaderProps {
70
+ conversation: ConversationSummary
71
+ busy?: boolean
72
+ onTakeover?: () => void
73
+ onReturnToBot?: () => void
74
+ onFinish?: () => void
75
+ onDownload?: () => void
76
+ onOpenDocuments?: () => void
77
+ documentsOpen?: boolean
78
+ /** Ações do produto que não existem no contrato do pacote (ex.: ferramentas de dev). */
79
+ extraUtilities?: readonly ConversationHeaderUtility[]
80
+ onBack?: () => void
81
+ labels?: Partial<ConversationHeaderLabels>
82
+ className?: string
83
+ classNames?: Partial<ConversationHeaderClassNames>
84
+ }
85
+
86
+ export function ConversationHeader({
87
+ conversation,
88
+ busy = false,
89
+ onTakeover,
90
+ onReturnToBot,
91
+ onFinish,
92
+ onDownload,
93
+ onOpenDocuments,
94
+ documentsOpen = false,
95
+ extraUtilities,
96
+ onBack,
97
+ labels: labelsOverride,
98
+ className,
99
+ classNames,
100
+ }: ConversationHeaderProps) {
101
+ const labels = { ...DEFAULT_CONVERSATION_HEADER_LABELS, ...labelsOverride }
102
+ const isHuman = conversation.mode === 'human'
103
+ const handle = conversation.contactId ?? conversation.whatsappNumber
104
+ const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
105
+ const flag = contactFlag({ handle, channel: conversation.channel })
106
+ const capabilities = capabilitiesOf(conversation.channel)
107
+ const [menuOpen, setMenuOpen] = useState(false)
108
+
109
+ // Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
110
+ // caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
111
+ const utilities = [
112
+ onOpenDocuments
113
+ ? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
114
+ : undefined,
115
+ onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
116
+ ...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
117
+ ].filter(
118
+ (utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
119
+ Boolean(utility),
120
+ )
121
+
122
+ // Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
123
+ // superfícies nunca divergirem sobre o que está disponível.
124
+ const actions = [
125
+ !isHuman && onTakeover
126
+ ? { key: 'takeover', label: labels.takeover, run: onTakeover, className: 'cv-header-action--primary' }
127
+ : undefined,
128
+ isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
129
+ isHuman && onFinish
130
+ ? { key: 'finish', label: `✕ ${labels.finish}`, run: onFinish, className: 'cv-header-action--danger' }
131
+ : undefined,
132
+ ].filter((action): action is { key: string; label: string; run: () => void; className: string } => Boolean(action))
133
+
134
+ // No celular utilitários e ações moram no mesmo menu: utilitários primeiro, porque são consulta e
135
+ // não alteram estado da conversa.
136
+ const menuItems = [
137
+ ...utilities.map((utility) => ({
138
+ key: utility.key,
139
+ label: `${utility.icon} ${utility.label}`,
140
+ run: utility.run,
141
+ className: '',
142
+ })),
143
+ ...actions,
144
+ ]
145
+
146
+ // `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
147
+ // empurrava as ações para uma segunda linha — e o menu, ancorado ao próprio botão, passava a abrir
148
+ // fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
149
+ return (
150
+ <header
151
+ className={cn(
152
+ 'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
153
+ classNames?.root,
154
+ className,
155
+ )}
156
+ >
157
+ <div className={cn('flex min-w-0 flex-1 items-center gap-3', classNames?.identity)}>
158
+ {/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
159
+ uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
160
+ {onBack ? (
161
+ <button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} title={labels.back}>
162
+ ←
163
+ </button>
164
+ ) : null}
165
+ {/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
166
+ <Avatar name={conversation.clientName} size="md" />
167
+ <div className="min-w-0">
168
+ <p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
169
+ <p className={cn('truncate text-xs text-gray-500', classNames?.meta)}>
170
+ {/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
171
+ o que rotulava qualquer contato como brasileiro. */}
172
+ {flag ? `${flag} ` : ''}
173
+ {displayHandle}
174
+ {/* Em tela estreita sobra o ícone do canal e o emoji do modo: o rótulo escrito empurra o
175
+ telefone para fora e não diz nada que o ícone já não diga. */}
176
+ <span className="ml-2 inline-flex items-center gap-1">
177
+ <ChannelIcon channel={conversation.channel} />
178
+ <span className="cv-only-wide">{capabilities.label}</span>
179
+ </span>
180
+ <span className="ml-2 cv-only-wide">{isHuman ? labels.humanMode : labels.botMode}</span>
181
+ <span className="ml-1 cv-only-narrow" title={isHuman ? labels.humanMode : labels.botMode}>
182
+ {isHuman ? '🧑‍💼' : '🤖'}
183
+ </span>
184
+ </p>
185
+ </div>
186
+ </div>
187
+
188
+ <div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
189
+ {/* Desktop: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
190
+ <div className={cn('hidden items-center gap-2 lg:flex', classNames?.desktopActions)}>
191
+ {utilities.map((utility) => (
192
+ <button
193
+ key={utility.key}
194
+ type="button"
195
+ onClick={utility.run}
196
+ disabled={busy}
197
+ aria-pressed={utility.active}
198
+ title={utility.label}
199
+ aria-label={utility.label}
200
+ className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
201
+ >
202
+ {utility.icon}
203
+ </button>
204
+ ))}
205
+ {actions.map((action) => (
206
+ <button
207
+ key={action.key}
208
+ type="button"
209
+ onClick={action.run}
210
+ disabled={busy}
211
+ className={`cv-header-action ${action.className}`}
212
+ >
213
+ {action.label}
214
+ </button>
215
+ ))}
216
+ </div>
217
+
218
+ {/* Celular: um único ⋮ com tudo dentro. Três ícones de 30px lado a lado violavam o mínimo
219
+ de 44px de área de toque e disputavam espaço com o nome do cliente. */}
220
+ {menuItems.length > 0 ? (
221
+ <div className={cn('cv-menu lg:hidden', classNames?.mobileMenu)}>
222
+ <button
223
+ type="button"
224
+ onClick={() => setMenuOpen(!menuOpen)}
225
+ aria-expanded={menuOpen}
226
+ aria-label={labels.moreActions}
227
+ title={labels.moreActions}
228
+ className="cv-header-icon cv-touch"
229
+ >
230
+ ⋮
231
+ </button>
232
+
233
+ {menuOpen ? (
234
+ <div className="cv-menu-panel" role="menu">
235
+ {menuItems.map((item) => (
236
+ <button
237
+ key={item.key}
238
+ type="button"
239
+ role="menuitem"
240
+ onClick={() => {
241
+ setMenuOpen(false)
242
+ item.run()
243
+ }}
244
+ disabled={busy}
245
+ className={`cv-header-action cv-touch ${item.className}`}
246
+ >
247
+ {item.label}
248
+ </button>
249
+ ))}
250
+ </div>
251
+ ) : null}
252
+ </div>
253
+ ) : null}
254
+ </div>
255
+ </header>
256
+ )
257
+ }