@adatechnology/conversations-ui 0.1.0-rc.5 → 0.1.0-rc.7

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 (42) hide show
  1. package/dist/chunk-TGTBMMFC.js +1707 -0
  2. package/dist/index.d.ts +209 -22
  3. package/dist/index.js +243 -590
  4. package/dist/preview/index.d.ts +153 -9
  5. package/dist/preview/index.js +520 -38
  6. package/dist/{types-C2Yexi8A.d.ts → types-B5C1DLu1.d.ts} +70 -2
  7. package/package.json +2 -2
  8. package/src/Avatar.tsx +13 -2
  9. package/src/ConversationHeader.tsx +18 -0
  10. package/src/ConversationListItem.tsx +18 -2
  11. package/src/DocumentsLibrary.tsx +322 -0
  12. package/src/EmojiPicker.tsx +69 -55
  13. package/src/FileIcon.test.ts +46 -1
  14. package/src/FileIcon.tsx +76 -10
  15. package/src/InteractiveMessage.tsx +126 -0
  16. package/src/Lightbox.tsx +18 -3
  17. package/src/MessageBubble.tsx +31 -4
  18. package/src/MessageComposer.tsx +36 -5
  19. package/src/WhatsAppMessageEditor.tsx +28 -4
  20. package/src/emojiCatalog.test.ts +35 -0
  21. package/src/emojiCatalog.ts +189 -0
  22. package/src/index.ts +25 -12
  23. package/src/lib/createMediaUrlResolver.ts +33 -0
  24. package/src/preview/AudioRecorderButton.tsx +117 -0
  25. package/src/preview/ConversationPreview.tsx +184 -15
  26. package/src/preview/MediaTypesPreview.tsx +87 -0
  27. package/src/preview/audioRecorderFormat.test.ts +67 -0
  28. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  29. package/src/preview/createMockConversationsApi.ts +62 -8
  30. package/src/preview/createPreviewWebhookClient.ts +28 -1
  31. package/src/preview/index.ts +16 -2
  32. package/src/preview/mediaTypeOf.test.ts +15 -0
  33. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  34. package/src/preview/previewFileSamples.test.ts +151 -0
  35. package/src/preview/previewFileSamples.ts +74 -0
  36. package/src/preview/previewFixtures.ts +140 -8
  37. package/src/preview/previewMediaSource.test.ts +62 -0
  38. package/src/preview/previewMediaSource.ts +91 -0
  39. package/src/providers/types.ts +17 -0
  40. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  41. package/src/types.ts +38 -1
  42. package/dist/chunk-YWITIIHD.js +0 -728
@@ -1,3 +1,5 @@
1
+ import * as react from 'react';
2
+
1
3
  interface ConversationsUIConfig {
2
4
  apiBaseUrl: string;
3
5
  theme?: ConversationsTheme;
@@ -17,9 +19,50 @@ interface ConversationsFeatures {
17
19
  emoji?: boolean;
18
20
  darkMode?: boolean;
19
21
  }
22
+ /**
23
+ * Recorte do bloco `interactive` da Meta que a UI precisa para desenhar o menu. Fica solto (e não
24
+ * espelhando o contrato inteiro) porque o que chega do banco é o payload cru já enviado ao
25
+ * WhatsApp: qualquer campo que a UI não conheça é ignorado, nunca causa erro de render.
26
+ */
27
+ interface InteractiveOption {
28
+ id: string;
29
+ title: string;
30
+ description?: string;
31
+ }
32
+ interface InteractiveSection {
33
+ title?: string;
34
+ rows?: InteractiveOption[];
35
+ }
36
+ interface InteractivePayload {
37
+ type?: 'button' | 'list' | string;
38
+ header?: {
39
+ text?: string;
40
+ };
41
+ body?: {
42
+ text?: string;
43
+ };
44
+ footer?: {
45
+ text?: string;
46
+ };
47
+ action?: {
48
+ /** Rótulo do botão que abre a lista — só existe em `type: 'list'`. */
49
+ button?: string;
50
+ sections?: InteractiveSection[];
51
+ buttons?: {
52
+ reply?: InteractiveOption;
53
+ }[];
54
+ };
55
+ }
56
+ /** Como o cliente respondeu a um menu: por botão ou por item de lista. */
57
+ type InteractiveSelection = {
58
+ readonly kind: 'button' | 'list';
59
+ readonly option: InteractiveOption;
60
+ };
20
61
  interface MessagePayload {
21
62
  id: string;
22
- type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template';
63
+ type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template' | 'interactive';
64
+ /** Payload cru da mensagem. Em `type: 'interactive'`, carrega o menu que o cliente vê. */
65
+ payload?: InteractivePayload | null;
23
66
  content?: string;
24
67
  caption?: string;
25
68
  mediaUrl?: string;
@@ -51,6 +94,16 @@ interface MessagePayload {
51
94
  isLastInGroup?: boolean;
52
95
  }
53
96
 
97
+ type ResolveMediaUrl = (message: MessagePayload) => Promise<string | null>;
98
+ interface MediaRendererProps {
99
+ message: MessagePayload;
100
+ onLightbox: (src: string) => void;
101
+ onResolveUrl?: ResolveMediaUrl;
102
+ /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
103
+ className?: string;
104
+ }
105
+ declare function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps): react.JSX.Element | null;
106
+
54
107
  /**
55
108
  * Canal de origem da conversa e o que cada um permite.
56
109
  *
@@ -154,6 +207,14 @@ interface ListDocumentsParams {
154
207
  source?: string;
155
208
  sortDirection?: 'asc' | 'desc';
156
209
  }
210
+ /** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
211
+ interface CompanyDocument extends ConversationDocument {
212
+ conversationId: string;
213
+ }
214
+ interface CompanyDocumentPage {
215
+ documents: CompanyDocument[];
216
+ total: number;
217
+ }
157
218
  interface ConversationDocumentPage {
158
219
  documents: ConversationDocument[];
159
220
  total: number;
@@ -212,6 +273,13 @@ interface ConversationsApi {
212
273
  * lote em vez de oferecer um botão que falha.
213
274
  */
214
275
  downloadDocumentsArchive?(conversationId: string, uploadIds: readonly string[]): Promise<Blob>;
276
+ /**
277
+ * Biblioteca de TODAS as conversas, para uma tela de Documentos fora do atendimento.
278
+ *
279
+ * Opcional por capacidade: host que só expõe anexo dentro da conversa não implementa, e o
280
+ * componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
281
+ */
282
+ getAllDocuments?(params?: ListDocumentsParams): Promise<CompanyDocumentPage>;
215
283
  getMediaProxyUrl(mediaId: string): Promise<{
216
284
  mimeType: string;
217
285
  data: string;
@@ -294,4 +362,4 @@ interface ConversationDocument {
294
362
  linkedAt: string;
295
363
  }
296
364
 
297
- export { CHANNEL_CAPABILITIES as C, DEFAULT_CONVERSATION_CHANNEL as D, type FormatContactHandleParams as F, HANDLE_KIND as H, type ListConversationsParams as L, type MessagePayload as M, REOPEN_MECHANISM as R, type SSEProvider as S, CHANNEL_FILTER_ALL as a, CONVERSATION_CHANNEL as b, type ChannelCapabilities as c, type ChannelFilter as d, type ChannelFilterOption as e, type ConversationChannel as f, type ConversationDocument as g, type ConversationDocumentPage as h, type ConversationEventSource as i, type ConversationPage as j, type ConversationSummary as k, type ConversationTemplate as l, type ConversationsApi as m, type ConversationsFeatures as n, type ConversationsTheme as o, type ConversationsUIConfig as p, type HandleKind as q, type ListDocumentsParams as r, type ReopenMechanism as s, capabilitiesOf as t, channelFiltersFor as u, contactFlag as v, formatContactHandle as w };
365
+ export { type ResolveMediaUrl as A, capabilitiesOf as B, CHANNEL_CAPABILITIES as C, DEFAULT_CONVERSATION_CHANNEL as D, channelFiltersFor as E, type FormatContactHandleParams as F, contactFlag as G, HANDLE_KIND as H, type InteractiveOption as I, formatContactHandle as J, type ListConversationsParams as L, MediaRenderer as M, REOPEN_MECHANISM as R, type SSEProvider as S, CHANNEL_FILTER_ALL as a, CONVERSATION_CHANNEL as b, type ChannelCapabilities as c, type ChannelFilter as d, type ChannelFilterOption as e, type CompanyDocument as f, type CompanyDocumentPage as g, type ConversationChannel as h, type ConversationDocument as i, type ConversationDocumentPage as j, type ConversationEventSource as k, type ConversationPage as l, type ConversationSummary as m, type ConversationTemplate as n, type ConversationsApi as o, type ConversationsFeatures as p, type ConversationsTheme as q, type ConversationsUIConfig as r, type HandleKind as s, type InteractivePayload as t, type InteractiveSection as u, type InteractiveSelection as v, type ListDocumentsParams as w, type MediaRendererProps as x, type MessagePayload as y, type ReopenMechanism as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.0-rc.7",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^1.21.0",
33
33
  "tailwind-merge": "^3.6.0",
34
- "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.5"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.6"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^18 || ^19",
package/src/Avatar.tsx CHANGED
@@ -1,8 +1,18 @@
1
+ export interface AvatarLabels {
2
+ /** Lido por leitor de tela quando não há nome nem imagem — a silhueta genérica. */
3
+ unnamedContact: string
4
+ }
5
+
1
6
  export interface AvatarProps {
2
7
  name?: string | null
3
8
  avatarUrl?: string
4
9
  size?: 'sm' | 'md' | 'lg'
5
10
  className?: string
11
+ labels?: Partial<AvatarLabels>
12
+ }
13
+
14
+ export const DEFAULT_AVATAR_LABELS: AvatarLabels = {
15
+ unnamedContact: 'Contato sem nome',
6
16
  }
7
17
 
8
18
  const sizeClasses = {
@@ -36,8 +46,9 @@ function getBgColor(name: string | undefined | null): string {
36
46
 
37
47
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/Avatar.tsx —
38
48
  // mesmas iniciais (primeira+última letra), mesmo hash de cor, mesmas classes de tamanho.
39
- export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarProps) {
49
+ export function Avatar({ name, avatarUrl, size = 'md', className = '', labels }: AvatarProps) {
40
50
  const sizeClass = sizeClasses[size]
51
+ const unnamedContactLabel = labels?.unnamedContact ?? DEFAULT_AVATAR_LABELS.unnamedContact
41
52
 
42
53
  if (avatarUrl) {
43
54
  return (
@@ -58,7 +69,7 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarP
58
69
  <div
59
70
  className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white flex-shrink-0 ${className}`}
60
71
  role="img"
61
- aria-label="Contato sem nome"
72
+ aria-label={unnamedContactLabel}
62
73
  >
63
74
  <svg viewBox="0 0 24 24" fill="currentColor" className="h-[60%] w-[60%]" aria-hidden>
64
75
  <path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5Z" />
@@ -52,6 +52,20 @@ export interface ConversationHeaderClassNames {
52
52
  mobileMenu: string
53
53
  }
54
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
+
55
69
  export interface ConversationHeaderProps {
56
70
  conversation: ConversationSummary
57
71
  busy?: boolean
@@ -61,6 +75,8 @@ export interface ConversationHeaderProps {
61
75
  onDownload?: () => void
62
76
  onOpenDocuments?: () => void
63
77
  documentsOpen?: boolean
78
+ /** Ações do produto que não existem no contrato do pacote (ex.: ferramentas de dev). */
79
+ extraUtilities?: readonly ConversationHeaderUtility[]
64
80
  onBack?: () => void
65
81
  labels?: Partial<ConversationHeaderLabels>
66
82
  className?: string
@@ -76,6 +92,7 @@ export function ConversationHeader({
76
92
  onDownload,
77
93
  onOpenDocuments,
78
94
  documentsOpen = false,
95
+ extraUtilities,
79
96
  onBack,
80
97
  labels: labelsOverride,
81
98
  className,
@@ -96,6 +113,7 @@ export function ConversationHeader({
96
113
  ? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
97
114
  : undefined,
98
115
  onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
116
+ ...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
99
117
  ].filter(
100
118
  (utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
101
119
  Boolean(utility),
@@ -3,8 +3,21 @@ import { Avatar } from './Avatar'
3
3
  import { contactFlag, formatContactHandle } from './conversationChannel'
4
4
  import type { ConversationSummary } from './providers/types'
5
5
 
6
+ export interface ConversationListItemLabels {
7
+ /** Tooltip do ponto vermelho: a janela de atendimento de 24h já fechou. */
8
+ expiredWindow: string
9
+ /** Tooltip do ponto laranja: a janela de atendimento está perto de fechar. */
10
+ warningWindow: string
11
+ }
12
+
13
+ export const DEFAULT_CONVERSATION_LIST_ITEM_LABELS: ConversationListItemLabels = {
14
+ expiredWindow: 'Janela expirada',
15
+ warningWindow: 'Janela próxima do fim',
16
+ }
17
+
6
18
  export interface ConversationListItemProps {
7
19
  conversation: ConversationSummary
20
+ labels?: Partial<ConversationListItemLabels>
8
21
  active?: boolean
9
22
  selected?: boolean
10
23
  onClick?: () => void
@@ -92,7 +105,10 @@ export const ConversationListItem = ({
92
105
  onSelect,
93
106
  showDivider = true,
94
107
  highlightActive = true,
108
+ labels,
95
109
  }: ConversationListItemProps) => {
110
+ const expiredWindowLabel = labels?.expiredWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.expiredWindow
111
+ const warningWindowLabel = labels?.warningWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.warningWindow
96
112
  const isActive = active || selected
97
113
  const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
98
114
  const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
@@ -134,10 +150,10 @@ export const ConversationListItem = ({
134
150
  {!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
135
151
  <span className="text-[16px] text-[#111b21] truncate">{name}</span>
136
152
  {windowStatus && windowStatus.label === 'expired' && (
137
- <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
153
+ <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title={expiredWindowLabel} />
138
154
  )}
139
155
  {windowStatus && windowStatus.label === 'warning' && (
140
- <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title="Janela próxima do fim" />
156
+ <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title={warningWindowLabel} />
141
157
  )}
142
158
  </div>
143
159
  {timestamp && (
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Biblioteca de arquivos de TODAS as conversas — a tela de Documentos do painel, fora do
3
+ * atendimento.
4
+ *
5
+ * Distinta do `ConversationDocumentsPanel`: aquele parte de uma conversa aberta e vive dentro dela;
6
+ * esta varre a empresa e por isso mostra de qual conversa cada arquivo veio, com o número
7
+ * clicável. Sem essa referência, uma lista global de anexos não responde nenhuma pergunta.
8
+ */
9
+
10
+ import { useEffect, useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Users } from 'lucide-react'
12
+ import { useConversations } from './providers/ConversationsProvider'
13
+ import { DOCUMENT_SOURCE_FILTER, type DocumentSourceFilter } from './ConversationDocumentsPanel'
14
+ import { FileIcon } from './FileIcon'
15
+ import { cn } from './lib/cn'
16
+ import { formatDateTime, formatFileSize } from './lib/format'
17
+ import { formatPhone } from './lib/phone'
18
+ import type { CompanyDocument } from './providers/types'
19
+
20
+ export interface DocumentsLibraryLabels {
21
+ title: string
22
+ searchPlaceholder: string
23
+ empty: string
24
+ noResults: string
25
+ loading: string
26
+ failure: string
27
+ view: string
28
+ download: string
29
+ openConversation: string
30
+ sourceFilterAll: string
31
+ sourceFilterCustomer: string
32
+ sourceFilterTeam: string
33
+ sortMostRecent: string
34
+ sortOldest: string
35
+ clearFilters: string
36
+ total: (count: number) => string
37
+ page: (current: number, last: number) => string
38
+ }
39
+
40
+ export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
41
+ title: 'Documentos',
42
+ searchPlaceholder: 'Buscar por nome do arquivo ou telefone',
43
+ empty: 'Nenhum arquivo trocado ainda.',
44
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
45
+ loading: 'Carregando arquivos…',
46
+ failure: 'Não foi possível carregar os arquivos.',
47
+ view: 'Visualizar',
48
+ download: 'Baixar',
49
+ openConversation: 'Abrir conversa',
50
+ sourceFilterAll: 'Todas as origens',
51
+ sourceFilterCustomer: 'Cliente',
52
+ sourceFilterTeam: 'Equipe',
53
+ sortMostRecent: 'Mais recentes',
54
+ sortOldest: 'Mais antigos',
55
+ clearFilters: 'Limpar filtros',
56
+ total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
57
+ page: (current: number, last: number) => `${current} / ${last}`,
58
+ }
59
+
60
+ export interface DocumentsLibraryClassNames {
61
+ root: string
62
+ title: string
63
+ filters: string
64
+ search: string
65
+ sourceSelect: string
66
+ sortButton: string
67
+ clearButton: string
68
+ status: string
69
+ list: string
70
+ item: string
71
+ conversationLink: string
72
+ filename: string
73
+ meta: string
74
+ pagination: string
75
+ }
76
+
77
+ export interface DocumentsLibraryProps {
78
+ /** Itens por página. O total vem do servidor. */
79
+ perPage?: number
80
+ /** Abrir a conversa de origem. Ausente, o número aparece como texto e não como link. */
81
+ onOpenConversation?: (conversationId: string) => void
82
+ labels?: Partial<DocumentsLibraryLabels>
83
+ className?: string
84
+ classNames?: Partial<DocumentsLibraryClassNames>
85
+ }
86
+
87
+ const TEAM_SOURCES = new Set(['agent', 'bot'])
88
+ const DEFAULT_PER_PAGE = 20
89
+
90
+ export function DocumentsLibrary({
91
+ perPage = DEFAULT_PER_PAGE,
92
+ onOpenConversation,
93
+ labels: labelsOverride,
94
+ className,
95
+ classNames,
96
+ }: DocumentsLibraryProps) {
97
+ const labels = { ...DEFAULT_DOCUMENTS_LIBRARY_LABELS, ...labelsOverride }
98
+ const context = useConversations()
99
+ const [search, setSearch] = useState('')
100
+ const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
101
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
102
+ const [page, setPage] = useState(1)
103
+ const [documents, setDocuments] = useState<readonly CompanyDocument[]>([])
104
+ const [total, setTotal] = useState(0)
105
+ const [loading, setLoading] = useState(false)
106
+ const [failed, setFailed] = useState(false)
107
+
108
+ const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
109
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
110
+ const fetchAll = context?.api.getAllDocuments
111
+
112
+ useEffect(() => {
113
+ if (!fetchAll) return
114
+ let active = true
115
+ setLoading(true)
116
+ setFailed(false)
117
+
118
+ void fetchAll({
119
+ search,
120
+ page,
121
+ limit: perPage,
122
+ sortDirection,
123
+ ...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
124
+ })
125
+ .then((result) => {
126
+ // `active` evita que uma resposta antiga sobrescreva a nova: digitar rápido na busca dispara
127
+ // várias chamadas e a ordem de retorno não é garantida.
128
+ if (!active) return
129
+ setDocuments(result.documents)
130
+ setTotal(result.total)
131
+ })
132
+ .catch(() => {
133
+ if (active) setFailed(true)
134
+ })
135
+ .finally(() => {
136
+ if (active) setLoading(false)
137
+ })
138
+
139
+ return () => {
140
+ active = false
141
+ }
142
+ }, [fetchAll, search, sourceFilter, sortDirection, page, perPage])
143
+
144
+ function applyFilter(change: () => void): void {
145
+ change()
146
+ setPage(1)
147
+ }
148
+
149
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
150
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
151
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
152
+ }
153
+
154
+ // Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
155
+ // sempre e fazer parecer que a empresa não tem arquivo nenhum.
156
+ if (!fetchAll) return null
157
+
158
+ return (
159
+ <div className={cn('space-y-3', classNames?.root, className)}>
160
+ <h2 className={cn('text-lg font-semibold', classNames?.title)}>{labels.title}</h2>
161
+
162
+ <div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
163
+ <input
164
+ type="search"
165
+ value={search}
166
+ onChange={(event) => applyFilter(() => setSearch(event.target.value))}
167
+ placeholder={labels.searchPlaceholder}
168
+ aria-label={labels.searchPlaceholder}
169
+ className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-64', classNames?.search)}
170
+ />
171
+
172
+ <select
173
+ value={sourceFilter}
174
+ onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
175
+ aria-label={labels.sourceFilterAll}
176
+ className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-40', classNames?.sourceSelect)}
177
+ >
178
+ <option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
179
+ <option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
180
+ <option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
181
+ </select>
182
+
183
+ <button
184
+ type="button"
185
+ onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
186
+ className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
187
+ >
188
+ <ArrowUpDown size={14} />
189
+ {sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
190
+ </button>
191
+
192
+ {hasFilters ? (
193
+ <button
194
+ type="button"
195
+ onClick={() =>
196
+ applyFilter(() => {
197
+ setSearch('')
198
+ setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
199
+ setSortDirection('desc')
200
+ })
201
+ }
202
+ className={cn('cv-header-action', classNames?.clearButton)}
203
+ >
204
+ {labels.clearFilters}
205
+ </button>
206
+ ) : null}
207
+ </div>
208
+
209
+ {loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
210
+ {failed ? (
211
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
212
+ {labels.failure}
213
+ </p>
214
+ ) : null}
215
+ {!loading && !failed && documents.length === 0 ? (
216
+ <p className={cn('text-sm text-gray-500', classNames?.status)}>
217
+ {hasFilters ? labels.noResults : labels.empty}
218
+ </p>
219
+ ) : null}
220
+
221
+ <ul className={cn('space-y-2', classNames?.list)}>
222
+ {documents.map((document) => {
223
+ const isFromCustomer = !TEAM_SOURCES.has(document.source)
224
+ const SourceIcon = isFromCustomer ? Users : Bot
225
+
226
+ return (
227
+ <li
228
+ key={`${document.conversationId}:${document.id}`}
229
+ className={cn(
230
+ 'flex items-center justify-between gap-3 rounded-lg border px-3 py-2 dark:border-gray-700',
231
+ classNames?.item,
232
+ )}
233
+ >
234
+ <div className="flex min-w-0 flex-1 items-center gap-3">
235
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
236
+ <div className="min-w-0 flex-1">
237
+ <div className={cn('truncate text-sm font-medium', classNames?.filename)} title={document.filename}>
238
+ {document.filename}
239
+ </div>
240
+ <div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
241
+ <span className="inline-flex items-center gap-1">
242
+ <SourceIcon size={11} />
243
+ {isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
244
+ </span>
245
+ <span>·</span>
246
+ <span>{formatDateTime(document.linkedAt)}</span>
247
+ <span>·</span>
248
+ <span>{formatFileSize(document.sizeBytes)}</span>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ {/* A conversa de origem é o dado que só esta tela tem. Vira botão quando o host sabe
254
+ navegar; sem handler, fica texto — link que não leva a lugar nenhum é pior. */}
255
+ {onOpenConversation ? (
256
+ <button
257
+ type="button"
258
+ onClick={() => onOpenConversation(document.conversationId)}
259
+ title={labels.openConversation}
260
+ className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
261
+ >
262
+ <MessageSquare size={12} />
263
+ {formatPhone(document.conversationId)}
264
+ </button>
265
+ ) : (
266
+ <span className={cn('shrink-0 text-xs text-gray-500', classNames?.conversationLink)}>
267
+ {formatPhone(document.conversationId)}
268
+ </span>
269
+ )}
270
+
271
+ <div className="flex shrink-0 gap-1">
272
+ <button
273
+ type="button"
274
+ onClick={() => void handleOpen(document.id, 'inline')}
275
+ title={labels.view}
276
+ aria-label={`${labels.view}: ${document.filename}`}
277
+ className="cv-header-icon"
278
+ >
279
+ <Eye size={14} />
280
+ </button>
281
+ <button
282
+ type="button"
283
+ onClick={() => void handleOpen(document.id, 'attachment')}
284
+ title={labels.download}
285
+ aria-label={`${labels.download}: ${document.filename}`}
286
+ className="cv-header-icon"
287
+ >
288
+ <Download size={14} />
289
+ </button>
290
+ </div>
291
+ </li>
292
+ )
293
+ })}
294
+ </ul>
295
+
296
+ {total > perPage ? (
297
+ <div className={cn('flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700', classNames?.pagination)}>
298
+ <span className="text-gray-400">{labels.total(total)}</span>
299
+ <div className="flex items-center gap-2">
300
+ <button
301
+ type="button"
302
+ onClick={() => setPage(page - 1)}
303
+ disabled={page <= 1}
304
+ className="cv-header-icon disabled:opacity-40"
305
+ >
306
+ ‹
307
+ </button>
308
+ <span className="text-gray-500">{labels.page(page, lastPage)}</span>
309
+ <button
310
+ type="button"
311
+ onClick={() => setPage(page + 1)}
312
+ disabled={page >= lastPage}
313
+ className="cv-header-icon disabled:opacity-40"
314
+ >
315
+ ›
316
+ </button>
317
+ </div>
318
+ </div>
319
+ ) : null}
320
+ </div>
321
+ )
322
+ }