@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
@@ -0,0 +1,92 @@
1
+ import { Moon, Sun } from 'lucide-react'
2
+
3
+ import { cn } from './lib/cn'
4
+ import { ICON_SIZE_ACTION } from './icon.constant'
5
+
6
+ export interface DarkModeToggleLabels {
7
+ /** AΓ§Γ£o de ir para o tema escuro β€” Γ© o rΓ³tulo lido enquanto o tema claro estΓ‘ ativo. */
8
+ toDark: string
9
+ toLight: string
10
+ }
11
+
12
+ export interface DarkModeToggleClassNames {
13
+ /** O tamanho do Γ­cone vem daqui quando o host usa a prΓ³pria escala (`size-4`, `size-5`). */
14
+ icon?: string
15
+ label?: string
16
+ }
17
+
18
+ export interface DarkModeToggleProps {
19
+ isDark: boolean
20
+ onToggle: () => void
21
+ /** RΓ³tulo ao lado do Γ­cone. SΓ³-Γ­cone exige a Γ‘rea de toque mΓ­nima, que a classe `cv-touch` dΓ‘. */
22
+ showLabel?: boolean
23
+ labels?: Partial<DarkModeToggleLabels>
24
+ /**
25
+ * Substitui a aparΓͺncia do botΓ£o. As classes passam por `tailwind-merge`, entΓ£o utilitΓ‘rio do
26
+ * host vence o do pacote em vez de somar: `px-4` descarta o `px-3` daqui.
27
+ *
28
+ * A exceΓ§Γ£o Γ© utilitΓ‘rio de token do host (`rounded-panel`), que o merge nΓ£o reconhece como
29
+ * famΓ­lia e deixa passar junto com o `rounded-lg` do pacote β€” aΓ­ quem decide Γ© a ordem no CSS
30
+ * gerado, nΓ£o a do atributo. Nesse caso use a forma arbitrΓ‘ria (`rounded-[var(--radius-panel)]`),
31
+ * que o merge entende e substitui.
32
+ */
33
+ className?: string
34
+ classNames?: DarkModeToggleClassNames
35
+ }
36
+
37
+ export const DEFAULT_DARK_MODE_TOGGLE_LABELS: DarkModeToggleLabels = {
38
+ toDark: 'Tema escuro',
39
+ toLight: 'Tema claro',
40
+ }
41
+
42
+ /**
43
+ * Interruptor de tema, apresentacional: o estado vem do host, junto com `useDarkMode`.
44
+ *
45
+ * Ele nΓ£o monta o hook de propΓ³sito. `useDarkMode` Γ© o controlador β€” escreve a classe `dark` no
46
+ * `<html>` e persiste a escolha β€”, e uma barra lateral que tambΓ©m existe como gaveta fica montada
47
+ * duas vezes na mesma tela. Dois controladores guardam estados iniciais separados, e o segundo
48
+ * botΓ£o passa a mostrar o Γ­cone contrΓ‘rio ao tema que estΓ‘ no ar. Com o estado no host, o hook Γ©
49
+ * chamado uma vez acima dos dois e as duas cΓ³pias concordam.
50
+ *
51
+ * O rΓ³tulo nomeia o destino, nΓ£o o estado atual: no claro se lΓͺ "Tema escuro", que Γ© o que o clique
52
+ * faz. `aria-pressed` diria o oposto na mesma frase, entΓ£o fica de fora.
53
+ */
54
+ export function DarkModeToggle({
55
+ isDark,
56
+ onToggle,
57
+ showLabel = false,
58
+ labels,
59
+ className,
60
+ classNames,
61
+ }: DarkModeToggleProps) {
62
+ const label = isDark
63
+ ? (labels?.toLight ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toLight)
64
+ : (labels?.toDark ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toDark)
65
+
66
+ const Icon = isDark ? Sun : Moon
67
+
68
+ return (
69
+ <button
70
+ type="button"
71
+ onClick={onToggle}
72
+ data-cv-tooltip={showLabel ? undefined : label}
73
+ aria-label={label}
74
+ className={cn(
75
+ 'inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors',
76
+ 'hover:bg-gray-100 hover:text-gray-900',
77
+ 'dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100',
78
+ !showLabel && 'cv-touch',
79
+ className,
80
+ )}
81
+ >
82
+ {/* Sem `size` quando o host manda classe: a prop vira `width`/`height` no SVG e atributo
83
+ ganha da folha, deixando o Γ­cone fora da escala de tipografia do host. */}
84
+ <Icon
85
+ aria-hidden="true"
86
+ className={cn('shrink-0', classNames?.icon)}
87
+ size={classNames?.icon ? undefined : ICON_SIZE_ACTION}
88
+ />
89
+ {showLabel ? <span className={classNames?.label}>{label}</span> : null}
90
+ </button>
91
+ )
92
+ }
@@ -0,0 +1,382 @@
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, useRef, useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Upload, 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 { PAGINATION_LABELS } from './pagination.constant'
17
+ import { formatDateTime, formatFileSize } from './lib/format'
18
+ import { formatPhone } from './lib/phone'
19
+ import type { CompanyDocument } from './providers/types'
20
+
21
+ export interface DocumentsLibraryLabels {
22
+ title: string
23
+ searchPlaceholder: string
24
+ empty: string
25
+ noResults: string
26
+ loading: string
27
+ failure: string
28
+ view: string
29
+ download: string
30
+ openConversation: string
31
+ sourceFilterAll: string
32
+ sourceFilterCustomer: string
33
+ sourceFilterTeam: string
34
+ sortMostRecent: string
35
+ sortOldest: string
36
+ clearFilters: string
37
+ upload: string
38
+ uploadError: string
39
+ total: (count: number) => string
40
+ page: (current: number, last: number) => string
41
+ }
42
+
43
+ export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
44
+ title: 'Documentos',
45
+ searchPlaceholder: 'Buscar por nome do arquivo ou telefone',
46
+ empty: 'Nenhum arquivo trocado ainda.',
47
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
48
+ loading: 'Carregando arquivos…',
49
+ failure: 'NΓ£o foi possΓ­vel carregar os arquivos.',
50
+ view: 'Visualizar',
51
+ download: 'Baixar',
52
+ openConversation: 'Abrir conversa',
53
+ sourceFilterAll: 'Todas as origens',
54
+ sourceFilterCustomer: 'Cliente',
55
+ sourceFilterTeam: 'Equipe',
56
+ sortMostRecent: 'Mais recentes',
57
+ sortOldest: 'Mais antigos',
58
+ clearFilters: 'Limpar filtros',
59
+ upload: 'Enviar documento',
60
+ uploadError: 'NΓ£o foi possΓ­vel enviar o arquivo.',
61
+ total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
62
+ page: (current: number, last: number) => `${current} / ${last}`,
63
+ }
64
+
65
+ export interface DocumentsLibraryClassNames {
66
+ root: string
67
+ title: string
68
+ filters: string
69
+ search: string
70
+ sourceSelect: string
71
+ sortButton: string
72
+ clearButton: string
73
+ status: string
74
+ list: string
75
+ item: string
76
+ conversationLink: string
77
+ filename: string
78
+ meta: string
79
+ pagination: string
80
+ }
81
+
82
+ export interface DocumentsLibraryProps {
83
+ /** Itens por pΓ‘gina. O total vem do servidor. */
84
+ perPage?: number
85
+ /** Abrir a conversa de origem. Ausente, o nΓΊmero aparece como texto e nΓ£o como link. */
86
+ onOpenConversation?: (conversationId: string) => void
87
+ labels?: Partial<DocumentsLibraryLabels>
88
+ className?: string
89
+ classNames?: Partial<DocumentsLibraryClassNames>
90
+ }
91
+
92
+ const TEAM_SOURCES = new Set(['agent', 'bot'])
93
+ const DEFAULT_PER_PAGE = 20
94
+
95
+ export function DocumentsLibrary({
96
+ perPage = DEFAULT_PER_PAGE,
97
+ onOpenConversation,
98
+ labels: labelsOverride,
99
+ className,
100
+ classNames,
101
+ }: DocumentsLibraryProps) {
102
+ const labels = { ...DEFAULT_DOCUMENTS_LIBRARY_LABELS, ...labelsOverride }
103
+ const context = useConversations()
104
+ const [search, setSearch] = useState('')
105
+ const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
106
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
107
+ const [page, setPage] = useState(1)
108
+ const [documents, setDocuments] = useState<readonly CompanyDocument[]>([])
109
+ const [total, setTotal] = useState(0)
110
+ const [loading, setLoading] = useState(false)
111
+ const [failed, setFailed] = useState(false)
112
+
113
+ const [uploading, setUploading] = useState(false)
114
+ const [uploadFailed, setUploadFailed] = useState(false)
115
+ const [reloadToken, setReloadToken] = useState(0)
116
+ const fileInputRef = useRef<HTMLInputElement>(null)
117
+
118
+ const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
119
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
120
+ const fetchAll = context?.api.getAllDocuments
121
+ const uploadDocument = context?.api.uploadDocument
122
+
123
+ useEffect(() => {
124
+ if (!fetchAll) return
125
+ let active = true
126
+ setLoading(true)
127
+ setFailed(false)
128
+
129
+ void fetchAll({
130
+ search,
131
+ page,
132
+ limit: perPage,
133
+ sortDirection,
134
+ ...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
135
+ })
136
+ .then((result) => {
137
+ // `active` evita que uma resposta antiga sobrescreva a nova: digitar rΓ‘pido na busca dispara
138
+ // vΓ‘rias chamadas e a ordem de retorno nΓ£o Γ© garantida.
139
+ if (!active) return
140
+ setDocuments(result.documents)
141
+ setTotal(result.total)
142
+ })
143
+ .catch(() => {
144
+ if (active) setFailed(true)
145
+ })
146
+ .finally(() => {
147
+ if (active) setLoading(false)
148
+ })
149
+
150
+ return () => {
151
+ active = false
152
+ }
153
+ }, [fetchAll, search, sourceFilter, sortDirection, page, perPage, reloadToken])
154
+
155
+ function applyFilter(change: () => void): void {
156
+ change()
157
+ setPage(1)
158
+ }
159
+
160
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
161
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
162
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
163
+ }
164
+
165
+ async function handleUpload(file: File): Promise<void> {
166
+ if (!uploadDocument) return
167
+ setUploading(true)
168
+ setUploadFailed(false)
169
+ try {
170
+ await uploadDocument(file)
171
+ setReloadToken((token) => token + 1)
172
+ } catch {
173
+ setUploadFailed(true)
174
+ } finally {
175
+ setUploading(false)
176
+ }
177
+ }
178
+
179
+ // Host sem `getAllDocuments` nΓ£o tem o que mostrar aqui β€” some, em vez de renderizar vazio para
180
+ // sempre e fazer parecer que a empresa nΓ£o tem arquivo nenhum.
181
+ if (!fetchAll) return null
182
+
183
+ return (
184
+ <div className={cn('space-y-3', classNames?.root, className)}>
185
+ <h2 className={cn('text-lg font-semibold', classNames?.title)}>{labels.title}</h2>
186
+
187
+ <div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
188
+ <input
189
+ type="search"
190
+ value={search}
191
+ onChange={(event) => applyFilter(() => setSearch(event.target.value))}
192
+ placeholder={labels.searchPlaceholder}
193
+ aria-label={labels.searchPlaceholder}
194
+ className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-64', classNames?.search)}
195
+ />
196
+
197
+ <select
198
+ value={sourceFilter}
199
+ onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
200
+ aria-label={labels.sourceFilterAll}
201
+ className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-40', classNames?.sourceSelect)}
202
+ >
203
+ <option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
204
+ <option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
205
+ <option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
206
+ </select>
207
+
208
+ <button
209
+ data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
210
+ type="button"
211
+ onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
212
+ className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
213
+ >
214
+ <ArrowUpDown size={14} />
215
+ {sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
216
+ </button>
217
+
218
+ {hasFilters ? (
219
+ <button
220
+ data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
221
+ type="button"
222
+ onClick={() =>
223
+ applyFilter(() => {
224
+ setSearch('')
225
+ setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
226
+ setSortDirection('desc')
227
+ })
228
+ }
229
+ className={cn('cv-header-action', classNames?.clearButton)}
230
+ >
231
+ {labels.clearFilters}
232
+ </button>
233
+ ) : null}
234
+
235
+ {uploadDocument ? (
236
+ <>
237
+ <input
238
+ ref={fileInputRef}
239
+ type="file"
240
+ hidden
241
+ onChange={(event) => {
242
+ const file = event.target.files?.[0]
243
+ event.target.value = ''
244
+ if (file) void handleUpload(file)
245
+ }}
246
+ />
247
+ <button
248
+ data-cv-tooltip={labels.upload}
249
+ aria-label={labels.upload}
250
+ type="button"
251
+ onClick={() => fileInputRef.current?.click()}
252
+ disabled={uploading}
253
+ className="cv-header-action ml-auto inline-flex items-center gap-1 disabled:opacity-40"
254
+ >
255
+ <Upload size={14} aria-hidden="true" />
256
+ {labels.upload}
257
+ </button>
258
+ </>
259
+ ) : null}
260
+ </div>
261
+
262
+ {loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
263
+ {failed ? (
264
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
265
+ {labels.failure}
266
+ </p>
267
+ ) : null}
268
+ {uploadFailed ? (
269
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
270
+ {labels.uploadError}
271
+ </p>
272
+ ) : null}
273
+ {!loading && !failed && documents.length === 0 ? (
274
+ <p className={cn('text-sm text-gray-500', classNames?.status)}>
275
+ {hasFilters ? labels.noResults : labels.empty}
276
+ </p>
277
+ ) : null}
278
+
279
+ <ul className={cn('space-y-2', classNames?.list)}>
280
+ {documents.map((document) => {
281
+ const isFromCustomer = !TEAM_SOURCES.has(document.source)
282
+ const SourceIcon = isFromCustomer ? Users : Bot
283
+
284
+ return (
285
+ <li
286
+ key={`${document.conversationId}:${document.id}`}
287
+ className={cn(
288
+ 'flex items-center justify-between gap-3 rounded-lg border px-3 py-2 dark:border-gray-700',
289
+ classNames?.item,
290
+ )}
291
+ >
292
+ <div className="flex min-w-0 flex-1 items-center gap-3">
293
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
294
+ <div className="min-w-0 flex-1">
295
+ <div className={cn('truncate text-sm font-medium', classNames?.filename)} data-cv-tooltip={document.filename}>
296
+ {document.filename}
297
+ </div>
298
+ <div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
299
+ <span className="inline-flex items-center gap-1">
300
+ <SourceIcon size={11} />
301
+ {isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
302
+ </span>
303
+ <span>Β·</span>
304
+ <span>{formatDateTime(document.linkedAt)}</span>
305
+ <span>Β·</span>
306
+ <span>{formatFileSize(document.sizeBytes)}</span>
307
+ </div>
308
+ </div>
309
+ </div>
310
+
311
+ {/* A conversa de origem Γ© o dado que sΓ³ esta tela tem. Vira botΓ£o quando o host sabe
312
+ navegar; sem handler, fica texto β€” link que nΓ£o leva a lugar nenhum Γ© pior. */}
313
+ {onOpenConversation ? (
314
+ <button
315
+ type="button"
316
+ onClick={() => onOpenConversation(document.conversationId)}
317
+ data-cv-tooltip={labels.openConversation} aria-label={labels.openConversation}
318
+ className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
319
+ >
320
+ <MessageSquare size={12} />
321
+ {formatPhone(document.conversationId)}
322
+ </button>
323
+ ) : (
324
+ <span className={cn('shrink-0 text-xs text-gray-500', classNames?.conversationLink)}>
325
+ {formatPhone(document.conversationId)}
326
+ </span>
327
+ )}
328
+
329
+ <div className="flex shrink-0 gap-1">
330
+ <button
331
+ type="button"
332
+ onClick={() => void handleOpen(document.id, 'inline')}
333
+ data-cv-tooltip={labels.view}
334
+ aria-label={`${labels.view}: ${document.filename}`}
335
+ className="cv-header-icon"
336
+ >
337
+ <Eye size={14} />
338
+ </button>
339
+ <button
340
+ type="button"
341
+ onClick={() => void handleOpen(document.id, 'attachment')}
342
+ data-cv-tooltip={labels.download}
343
+ aria-label={`${labels.download}: ${document.filename}`}
344
+ className="cv-header-icon"
345
+ >
346
+ <Download size={14} />
347
+ </button>
348
+ </div>
349
+ </li>
350
+ )
351
+ })}
352
+ </ul>
353
+
354
+ {total > perPage ? (
355
+ <div className={cn('flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700', classNames?.pagination)}>
356
+ <span className="text-gray-400">{labels.total(total)}</span>
357
+ <div className="flex items-center gap-2">
358
+ <button
359
+ data-cv-tooltip={PAGINATION_LABELS.previous} aria-label={PAGINATION_LABELS.previous}
360
+ type="button"
361
+ onClick={() => setPage(page - 1)}
362
+ disabled={page <= 1}
363
+ className="cv-header-icon disabled:opacity-40"
364
+ >
365
+ β€Ή
366
+ </button>
367
+ <span className="text-gray-500">{labels.page(page, lastPage)}</span>
368
+ <button
369
+ data-cv-tooltip={PAGINATION_LABELS.next} aria-label={PAGINATION_LABELS.next}
370
+ type="button"
371
+ onClick={() => setPage(page + 1)}
372
+ disabled={page >= lastPage}
373
+ className="cv-header-icon disabled:opacity-40"
374
+ >
375
+ β€Ί
376
+ </button>
377
+ </div>
378
+ </div>
379
+ ) : null}
380
+ </div>
381
+ )
382
+ }
@@ -1,39 +1,27 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useMemo } from 'react'
2
+ import { EMOJI_CATEGORIES, searchEmojis, type EmojiEntry } from './emojiCatalog'
3
+
4
+ export interface EmojiPickerLabels {
5
+ search: string
6
+ noResults: string
7
+ }
8
+
9
+ export const DEFAULT_EMOJI_PICKER_LABELS: EmojiPickerLabels = {
10
+ search: 'Buscar emoji',
11
+ noResults: 'Nenhum emoji encontrado',
12
+ }
2
13
 
3
14
  export interface EmojiPickerProps {
4
15
  onSelect: (emoji: string) => void
16
+ labels?: Partial<EmojiPickerLabels>
5
17
  className?: string
6
18
  }
7
19
 
8
- const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
9
- {
10
- name: 'Smileys',
11
- emojis: ['πŸ˜€', 'πŸ˜ƒ', 'πŸ˜„', '😁', 'πŸ˜…', 'πŸ˜‚', '🀣', '😊', 'πŸ˜‡', 'πŸ™‚', 'πŸ˜‰', '😌', '😍', 'πŸ₯°', '😘', 'πŸ˜—', 'πŸ˜‹', 'πŸ˜›', '😜', 'πŸ€ͺ'],
12
- },
13
- {
14
- name: 'Gestures',
15
- emojis: ['πŸ‘', 'πŸ‘Ž', 'πŸ‘Œ', '✌️', '🀞', '🀟', '🀘', 'πŸ€™', 'πŸ‘‹', '🀚', 'πŸ–οΈ', 'βœ‹', 'πŸ––', 'πŸ‘', 'πŸ™Œ', '🀝', 'πŸ™', '✍️', 'πŸ’…', '🀳'],
16
- },
17
- {
18
- name: 'Hearts',
19
- emojis: ['❀️', '🧑', 'πŸ’›', 'πŸ’š', 'πŸ’™', 'πŸ’œ', 'πŸ–€', '🀍', '🀎', 'πŸ’”', '❣️', 'πŸ’•', 'πŸ’ž', 'πŸ’“', 'πŸ’—', 'πŸ’–', 'πŸ’˜', 'πŸ’', 'πŸ’Ÿ', 'β™₯️'],
20
- },
21
- {
22
- name: 'Food',
23
- emojis: ['πŸ”', '🍟', 'πŸ•', '🌭', '🍿', 'πŸ§‚', 'πŸ₯“', 'πŸ₯š', '🍳', 'πŸ§‡', 'πŸ₯ž', '🧈', '🍞', 'πŸ₯', 'πŸ₯¨', 'πŸ₯―', 'πŸ₯–', 'πŸ§€', 'πŸ₯—', 'πŸ₯™'],
24
- },
25
- {
26
- name: 'Drinks',
27
- emojis: ['β˜•', '🍡', '🍢', '🍾', '🍷', '🍸', '🍹', '🍺', '🍻', 'πŸ₯‚', 'πŸ₯ƒ', 'πŸ₯€', 'πŸ§‹', 'πŸ§ƒ', 'πŸ§‰', '🧊', 'πŸ₯’', '🍽️', '🍴', 'πŸ₯„'],
28
- },
29
- {
30
- name: 'Objects',
31
- emojis: ['🎁', 'πŸŽ‚', '🎈', 'πŸŽ‰', '🎊', 'πŸŽ€', 'πŸ“±', 'πŸ’»', '⌚', 'πŸ“·', 'πŸ”‘', 'πŸ’°', 'πŸ’³', 'πŸ“', 'πŸ“Œ', 'πŸ“', 'βœ‚οΈ', 'πŸ”', 'πŸ’‘', 'πŸ””'],
32
- },
33
- ]
34
-
35
- export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
20
+ export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerProps) => {
21
+ const searchLabel = labels?.search ?? DEFAULT_EMOJI_PICKER_LABELS.search
22
+ const noResultsLabel = labels?.noResults ?? DEFAULT_EMOJI_PICKER_LABELS.noResults
36
23
  const [activeCategory, setActiveCategory] = useState(0)
24
+ const [query, setQuery] = useState('')
37
25
 
38
26
  const handleSelect = useCallback(
39
27
  (emoji: string) => {
@@ -42,36 +30,63 @@ export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
42
30
  [onSelect],
43
31
  )
44
32
 
33
+ // Buscando, as abas de categoria saem do caminho: o resultado atravessa todas elas, e manter uma
34
+ // aba destacada sugeriria que a busca estΓ‘ restrita Γ quela categoria.
35
+ const isSearching = query.trim().length > 0
36
+ const visibleEntries: readonly EmojiEntry[] = useMemo(
37
+ () => (isSearching ? searchEmojis(query) : EMOJI_CATEGORIES[activeCategory].entries),
38
+ [isSearching, query, activeCategory],
39
+ )
40
+
45
41
  return (
46
42
  <div className={`bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden ${className}`}>
47
- <div className="flex border-b border-gray-200 overflow-x-auto">
48
- {EMOJI_CATEGORIES.map((category, index) => (
49
- <button
50
- key={category.name}
51
- onClick={() => setActiveCategory(index)}
52
- className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
53
- activeCategory === index
54
- ? 'border-blue-500 text-blue-600'
55
- : 'border-transparent text-gray-500 hover:text-gray-700'
56
- }`}
57
- >
58
- {category.emojis[0]} {category.name}
59
- </button>
60
- ))}
43
+ <div className="p-2 border-b border-gray-200">
44
+ <input
45
+ type="search"
46
+ value={query}
47
+ onChange={(event) => setQuery(event.target.value)}
48
+ placeholder={searchLabel}
49
+ aria-label={searchLabel}
50
+ className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm outline-none focus:border-blue-500"
51
+ />
61
52
  </div>
62
53
 
63
- <div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
64
- {EMOJI_CATEGORIES[activeCategory].emojis.map((emoji) => (
65
- <button
66
- key={emoji}
67
- onClick={() => handleSelect(emoji)}
68
- className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
69
- aria-label={emoji}
70
- >
71
- {emoji}
72
- </button>
73
- ))}
74
- </div>
54
+ {isSearching ? null : (
55
+ <div className="flex border-b border-gray-200 overflow-x-auto">
56
+ {EMOJI_CATEGORIES.map((category, index) => (
57
+ <button
58
+ data-cv-tooltip={category.name} aria-label={category.name}
59
+ key={category.name}
60
+ onClick={() => setActiveCategory(index)}
61
+ className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
62
+ activeCategory === index
63
+ ? 'border-blue-500 text-blue-600'
64
+ : 'border-transparent text-gray-500 hover:text-gray-700'
65
+ }`}
66
+ >
67
+ {category.entries[0].emoji} {category.name}
68
+ </button>
69
+ ))}
70
+ </div>
71
+ )}
72
+
73
+ {visibleEntries.length === 0 ? (
74
+ <p className="px-3 py-6 text-center text-sm text-gray-500">{noResultsLabel}</p>
75
+ ) : (
76
+ <div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
77
+ {visibleEntries.map((entry) => (
78
+ <button
79
+ key={entry.emoji}
80
+ onClick={() => handleSelect(entry.emoji)}
81
+ className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
82
+ aria-label={entry.emoji}
83
+ data-cv-tooltip={entry.keywords[0]}
84
+ >
85
+ {entry.emoji}
86
+ </button>
87
+ ))}
88
+ </div>
89
+ )}
75
90
  </div>
76
91
  )
77
92
  }