@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,432 @@
1
+ /**
2
+ * Coluna do meio: cabeçalho, contexto, documentos, transcript e composer da conversa aberta.
3
+ *
4
+ * Era a peça mais copiada entre os produtos, e a que mais divergia: um perdia o salto para a
5
+ * última mensagem, outro engolia falha de anexo, outro não abria a biblioteca de arquivos.
6
+ */
7
+
8
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
9
+
10
+ import { AudioRecorderButton } from '../AudioRecorderButton'
11
+ import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
12
+ import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
13
+ import { ConversationHeader, type ConversationHeaderUtility } from '../ConversationHeader'
14
+ import { ConversationWallpaper } from '../Wallpaper'
15
+ import { DateDivider } from '../DateDivider'
16
+ import { MessageBubble } from '../MessageBubble'
17
+ import { MessageComposer, applyQuickReplyVariables, type QuickReply } from '../MessageComposer'
18
+ import { RichMessageComposer, type RichComposerVariable } from '../RichMessageComposer'
19
+ import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
20
+ import { windowOf } from '../conversationWindow'
21
+ import {
22
+ buildTranscriptFilename,
23
+ buildTranscriptText,
24
+ downloadTextFile,
25
+ } from '../conversationTranscript'
26
+ import { useConversationContext } from '../hooks/useConversationContext'
27
+ import { useConversationMessages } from '../hooks/useConversationMessages'
28
+ import { useConversationRealtime } from '../hooks/useConversationRealtime'
29
+ import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
30
+ import { useConversations } from '../providers/ConversationsProvider'
31
+ import type { ConversationSummary } from '../providers/types'
32
+ import type { ConversationsWorkspaceLabels } from './labels'
33
+
34
+ export interface ConversationPaneProps {
35
+ readonly conversation: ConversationSummary
36
+ readonly now: number
37
+ readonly busy: boolean
38
+ readonly labels: ConversationsWorkspaceLabels
39
+ /** Devolve a promessa quando o host a tem: o envio de template espera a tomada antes de sair. */
40
+ readonly onTakeover?: (() => void | Promise<void>) | undefined
41
+ readonly onReturnToBot?: (() => void) | undefined
42
+ readonly onFinish?: (() => void) | undefined
43
+ readonly onBack: () => void
44
+ readonly extraUtilities?: readonly ConversationHeaderUtility[]
45
+ /** Traduz o contexto cru do produto nas linhas do painel. Ausente, o painel não aparece. */
46
+ readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
47
+ readonly quickReplies?: readonly QuickReply[]
48
+ /**
49
+ * Recebe o contexto junto porque o dado que interessa à variável (o nome que o bot perguntou,
50
+ * por exemplo) vive no contexto do fluxo, não no resumo da listagem.
51
+ */
52
+ readonly quickReplyVariablesFor?: (
53
+ conversation: ConversationSummary,
54
+ context: Record<string, unknown> | undefined,
55
+ ) => Record<string, string>
56
+ /** Etapa do fluxo ao lado do contexto (ex.: "Anotando o pedido"). */
57
+ readonly flowLabel?: string | undefined
58
+ /**
59
+ * Substitui o download local do transcript. Existe para o produto que exporta pela rota do
60
+ * servidor, onde o arquivo sai completo em vez de só com o que a tela carregou.
61
+ */
62
+ readonly onDownload?: (() => void) | undefined
63
+ /**
64
+ * Bloqueia o composer enquanto a conversa estiver com o bot. Ligado, responder sem assumir
65
+ * atropelaria o fluxo automático no meio de uma pergunta.
66
+ */
67
+ readonly requireTakeoverToReply?: boolean
68
+ /**
69
+ * Deixa marcar mensagens e copiar o trecho. É o que se faz para levar um pedaço da conversa a um
70
+ * e-mail ou a um chamado, sem baixar o transcript inteiro.
71
+ */
72
+ readonly messageSelection?: boolean
73
+ /** Texto já no campo ao abrir (deep link que sugere a resposta). */
74
+ readonly initialComposerText?: string | undefined
75
+ /**
76
+ * Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). Recebe o
77
+ * contexto do fluxo junto: é dele que sai o resumo, e sem isso o host teria de buscá-lo de novo.
78
+ */
79
+ readonly renderAboveTranscript?: (
80
+ conversation: ConversationSummary,
81
+ context: Record<string, unknown> | undefined,
82
+ ) => ReactNode
83
+ readonly onAttach?: ((file: File) => Promise<void>) | undefined
84
+ /**
85
+ * `rich` troca o `textarea` pelo campo com a formatação do WhatsApp desenhada enquanto se escreve.
86
+ * Vale onde o atendente manda texto longo e formatado; para responder "já vou ver" o campo simples
87
+ * é menos coisa na tela.
88
+ */
89
+ readonly composer?: 'simple' | 'rich'
90
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
91
+ readonly composerVariablesFor?: (
92
+ conversation: ConversationSummary,
93
+ context: Record<string, unknown> | undefined,
94
+ ) => readonly RichComposerVariable[]
95
+ /**
96
+ * Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
97
+ * barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
98
+ * perde a legenda, e era a diferença entre as telas.
99
+ */
100
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
101
+ /** Grava e envia nota de voz. Ausente, o microfone não aparece. */
102
+ readonly onRecordAudio?: (file: File) => Promise<void>
103
+ }
104
+
105
+ export function ConversationPane({
106
+ conversation,
107
+ now,
108
+ busy,
109
+ labels,
110
+ onTakeover,
111
+ onReturnToBot,
112
+ onFinish,
113
+ onBack,
114
+ extraUtilities,
115
+ contextEntriesOf,
116
+ quickReplies,
117
+ quickReplyVariablesFor,
118
+ flowLabel,
119
+ onDownload,
120
+ requireTakeoverToReply,
121
+ messageSelection,
122
+ initialComposerText,
123
+ renderAboveTranscript,
124
+ onAttach,
125
+ composer = 'simple',
126
+ composerVariablesFor,
127
+ onSendAttachments,
128
+ onRecordAudio,
129
+ }: ConversationPaneProps) {
130
+ const context = useConversations()
131
+ if (!context) {
132
+ throw new Error('ConversationPane requires an ancestor <ConversationsProvider>')
133
+ }
134
+ const { api } = context
135
+
136
+ const { messages, refetch } = useConversationMessages(conversation.id)
137
+ const { context: conversationContext } = useConversationContext(conversation.id)
138
+ const [documentsOpen, setDocumentsOpen] = useState(false)
139
+ const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
140
+ const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
141
+ const [draft, setDraft] = useState(initialComposerText ?? '')
142
+ const [queuedFiles, setQueuedFiles] = useState<readonly File[]>([])
143
+ const [isSendingDraft, setIsSendingDraft] = useState(false)
144
+ /** Ref, não estado: entre dois cliques seguidos o React ainda não teria repintado a trava. */
145
+ const sendInFlightRef = useRef(false)
146
+
147
+ // Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
148
+ // levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
149
+ useEffect(() => {
150
+ setSelectedMessageIds(new Set())
151
+ setDraft(initialComposerText ?? '')
152
+ setQueuedFiles([])
153
+ }, [conversation.id, initialComposerText])
154
+
155
+ function toggleMessageSelected(messageId: string): void {
156
+ setSelectedMessageIds((current) => {
157
+ const next = new Set(current)
158
+ if (next.has(messageId)) next.delete(messageId)
159
+ else next.add(messageId)
160
+ return next
161
+ })
162
+ }
163
+
164
+ /** Data, quem falou e o texto: fora do painel o trecho precisa se sustentar sozinho. */
165
+ function copySelectedMessages(): void {
166
+ const transcript = messages
167
+ .filter((message) => selectedMessageIds.has(message.id))
168
+ .map(
169
+ (message) =>
170
+ `[${new Date(message.timestamp).toLocaleString()}] ${message.sender}: ${message.content ?? `(${message.type})`}`,
171
+ )
172
+ .join('\n')
173
+ void navigator.clipboard.writeText(transcript)
174
+ setSelectedMessageIds(new Set())
175
+ }
176
+
177
+ // Abrir no topo do histórico obrigava a rolar semanas até a última mensagem — que é sempre o que
178
+ // interessa. O hook salta ao trocar de conversa sem arrastar quem estiver lendo o histórico.
179
+ const scroll = useScrollToLatestMessage({ conversationId: conversation.id, messageCount: messages.length })
180
+
181
+ // O evento traz só `{ direction, sender }` — quem tem o conteúdo é a query.
182
+ useConversationRealtime(conversation.id, () => {
183
+ void refetch()
184
+ })
185
+
186
+ const blocked = isWindowBlocking(
187
+ windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel }),
188
+ )
189
+
190
+ /**
191
+ * Falha vira aviso na tela em vez de exceção silenciosa: o atendente escreveu ou gravou, achou
192
+ * que mandou, e sem retorno não teria como saber que o cliente não recebeu nada.
193
+ */
194
+ /** Devolve se o envio passou: limpar o rascunho depois de uma falha apagaria o texto do operador. */
195
+ async function runSend(action: () => Promise<unknown>, fallback: string): Promise<boolean> {
196
+ setSendFailure(undefined)
197
+ try {
198
+ await action()
199
+ await refetch()
200
+ return true
201
+ } catch (error: unknown) {
202
+ setSendFailure(error instanceof Error ? error.message : fallback)
203
+ return false
204
+ }
205
+ }
206
+
207
+ async function handleSend(text: string): Promise<boolean> {
208
+ return runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure)
209
+ }
210
+
211
+ /**
212
+ * Reabrir a janela é ato de atendente: quem manda o template quer conversar. A tomada vai antes do
213
+ * envio — se ficasse depois, a resposta do cliente chegaria com a conversa ainda no bot e o fluxo
214
+ * automático responderia por cima de quem acabou de reabri-la.
215
+ */
216
+ async function handleSendTemplate(): Promise<void> {
217
+ if (conversation.mode !== 'human' && onTakeover) await onTakeover()
218
+ await runSend(() => api.sendTemplate(conversation.id, {}), labels.sendFailure)
219
+ }
220
+
221
+ /**
222
+ * O texto escrito é a legenda do anexo, não uma segunda mensagem: no WhatsApp a foto chega com a
223
+ * frase embaixo, e mandar as duas separadas invertia a ordem quando a mídia demorava a subir.
224
+ */
225
+ async function handleRichSend(): Promise<void> {
226
+ // O upload da mídia demora e não dá retorno na tela; sem esta trava o segundo clique — ou o
227
+ // Enter impaciente — mandava o mesmo arquivo outra vez.
228
+ if (sendInFlightRef.current) return
229
+ sendInFlightRef.current = true
230
+ setIsSendingDraft(true)
231
+ try {
232
+ if (onSendAttachments && queuedFiles.length > 0) {
233
+ const files = queuedFiles
234
+ const caption = draft
235
+ const didSend = await runSend(() => onSendAttachments(files, caption), labels.attachFailure)
236
+ if (didSend) {
237
+ setQueuedFiles([])
238
+ setDraft('')
239
+ }
240
+ return
241
+ }
242
+ if (!draft.trim()) return
243
+ if (await handleSend(draft)) setDraft('')
244
+ } finally {
245
+ sendInFlightRef.current = false
246
+ setIsSendingDraft(false)
247
+ }
248
+ }
249
+
250
+ async function handleAttach(file: File): Promise<void> {
251
+ if (!onAttach) return
252
+ await runSend(() => onAttach(file), labels.attachFailure)
253
+ }
254
+
255
+ function handleDownload(): void {
256
+ downloadTextFile(
257
+ buildTranscriptFilename(conversation.whatsappNumber, new Date()),
258
+ buildTranscriptText({
259
+ messages,
260
+ whatsappNumber: conversation.whatsappNumber,
261
+ clientName: conversation.clientName,
262
+ }),
263
+ )
264
+ }
265
+
266
+ const contextEntries = contextEntriesOf?.(conversationContext)
267
+ const composerVariables = composerVariablesFor?.(conversation, conversationContext)
268
+ /**
269
+ * As mesmas `quickReplies` do composer simples, com as variáveis já resolvidas — o campo rico
270
+ * recebe texto pronto. Uma segunda lista, só de formato diferente, é como as telas divergiam.
271
+ */
272
+ const richQuickReplies = quickReplies?.map((reply) => ({
273
+ id: reply.key,
274
+ label: reply.label,
275
+ text:
276
+ typeof reply.text === 'string'
277
+ ? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
278
+ : reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {}),
279
+ }))
280
+ const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
281
+
282
+ return (
283
+ <div className="cv-workspace-pane">
284
+ <ConversationHeader
285
+ conversation={conversation}
286
+ busy={busy}
287
+ {...(onTakeover ? { onTakeover } : {})}
288
+ {...(onReturnToBot ? { onReturnToBot } : {})}
289
+ {...(onFinish ? { onFinish } : {})}
290
+ onDownload={onDownload ?? handleDownload}
291
+ onBack={onBack}
292
+ onOpenDocuments={() => setDocumentsOpen(!documentsOpen)}
293
+ documentsOpen={documentsOpen}
294
+ {...(extraUtilities ? { extraUtilities } : {})}
295
+ />
296
+
297
+ {(contextEntries && contextEntries.length > 0) || flowLabel ? (
298
+ <ConversationContextPanel entries={contextEntries ?? []} {...(flowLabel ? { flowLabel } : {})} />
299
+ ) : null}
300
+ <ConversationDocumentsPanel conversationId={conversation.id} open={documentsOpen} />
301
+
302
+ {renderAboveTranscript?.(conversation, conversationContext)}
303
+
304
+ {/* Mesmo wallpaper do preview do cliente: atendente e cliente devem ver a conversa com a
305
+ mesma aparência, senão o preview deixa de ser referência confiável. */}
306
+ <ConversationWallpaper
307
+ ref={scroll.containerRef}
308
+ onScroll={scroll.handleScroll}
309
+ className="cv-workspace-transcript"
310
+ >
311
+ {messages.map((message, index) => {
312
+ const previous = index > 0 ? messages[index - 1] : undefined
313
+ const startsNewDay =
314
+ !previous || new Date(message.timestamp).toDateString() !== new Date(previous.timestamp).toDateString()
315
+
316
+ return (
317
+ <div key={message.id}>
318
+ {startsNewDay ? <DateDivider iso={message.timestamp} /> : null}
319
+ <MessageBubble
320
+ message={message}
321
+ isMine={message.direction === 'outbound'}
322
+ isFirstInGroup={!previous || previous.sender !== message.sender}
323
+ {...(messageSelection
324
+ ? {
325
+ isSelecting: selectedMessageIds.size > 0,
326
+ isSelected: selectedMessageIds.has(message.id),
327
+ onToggleSelect: () => toggleMessageSelected(message.id),
328
+ }
329
+ : {})}
330
+ />
331
+ </div>
332
+ )
333
+ })}
334
+ </ConversationWallpaper>
335
+
336
+ {messageSelection && selectedMessageIds.size > 0 ? (
337
+ <div className="cv-workspace-selection">
338
+ <span>{labels.messagesSelected(selectedMessageIds.size)}</span>
339
+ <div className="cv-workspace-selection__actions">
340
+ <button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={() => setSelectedMessageIds(new Set())}>
341
+ {labels.bulkClear}
342
+ </button>
343
+ <button data-cv-tooltip={labels.copySelected} aria-label={labels.copySelected} type="button" onClick={copySelectedMessages}>
344
+ {labels.copySelected}
345
+ </button>
346
+ </div>
347
+ </div>
348
+ ) : null}
349
+
350
+ {sendFailure ? (
351
+ <p role="alert" className="cv-workspace-alert">
352
+ {sendFailure}
353
+ </p>
354
+ ) : null}
355
+
356
+ {blocked ? (
357
+ <WindowExpiredNotice
358
+ disabled={busy}
359
+ onSendTemplate={() => void handleSendTemplate()}
360
+ />
361
+ ) : botOwnsConversation ? (
362
+ // Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
363
+ <p className="cv-workspace-notice">{labels.takeoverToReply}</p>
364
+ ) : composer === 'rich' ? (
365
+ <RichMessageComposer
366
+ value={draft}
367
+ onChange={setDraft}
368
+ onSend={() => void handleRichSend()}
369
+ {...(onSendAttachments
370
+ ? { onAttachFiles: (files: FileList) => setQueuedFiles((current) => [...current, ...Array.from(files)]) }
371
+ : onAttach
372
+ ? {
373
+ onAttachFiles: (files: FileList) => {
374
+ for (const file of Array.from(files)) void handleAttach(file)
375
+ },
376
+ }
377
+ : {})}
378
+ placeholder={labels.composerPlaceholder}
379
+ isSending={busy || isSendingDraft}
380
+ hasQueuedAttachments={queuedFiles.length > 0}
381
+ {...(onRecordAudio
382
+ ? {
383
+ idleAction: (
384
+ <AudioRecorderButton
385
+ onRecorded={(file) => void runSend(() => onRecordAudio(file), labels.recordFailure)}
386
+ onFailure={(failureMessage) => setSendFailure(failureMessage)}
387
+ />
388
+ ),
389
+ }
390
+ : {})}
391
+ {...(queuedFiles.length > 0
392
+ ? {
393
+ attachmentsPreview: (
394
+ <ul className="cv-workspace-attachments">
395
+ {queuedFiles.map((file, index) => (
396
+ <li key={`${file.name}-${index}`}>
397
+ <span>{file.name}</span>
398
+ <button
399
+ data-cv-tooltip={labels.attachmentRemove}
400
+ type="button"
401
+ aria-label={labels.attachmentRemove}
402
+ onClick={() =>
403
+ setQueuedFiles((current) => current.filter((_, position) => position !== index))
404
+ }
405
+ >
406
+
407
+ </button>
408
+ </li>
409
+ ))}
410
+ </ul>
411
+ ),
412
+ }
413
+ : {})}
414
+ {...(richQuickReplies ? { quickReplies: [...richQuickReplies] } : {})}
415
+ {...(composerVariables ? { variables: [...composerVariables] } : {})}
416
+ />
417
+ ) : (
418
+ <MessageComposer
419
+ value={draft}
420
+ onChange={setDraft}
421
+ onSend={(text) => void handleSend(text)}
422
+ // Habilita clipe E microfone: o composer desenha o gravador sozinho quando existe um jeito
423
+ // de entregar arquivo, porque áudio gravado é um anexo como qualquer outro.
424
+ {...(onAttach ? { onAttach: (file: File) => void handleAttach(file) } : {})}
425
+ placeholder={labels.composerPlaceholder}
426
+ {...(quickReplies ? { quickReplies } : {})}
427
+ {...(quickReplyVariablesFor ? { quickReplyVariables: quickReplyVariablesFor(conversation, conversationContext) } : {})}
428
+ />
429
+ )}
430
+ </div>
431
+ )
432
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Coluna da esquerda: busca, filtros de janela e canal, seleção em massa, linhas e paginação.
3
+ *
4
+ * Recebe o resultado do hook inteiro em vez de vinte props: é a mesma máquina de estado, e passar
5
+ * peça por peça só criaria oportunidade de um produto ligar metade dos controles.
6
+ */
7
+
8
+ import type { ReactNode } from 'react'
9
+
10
+ import { ChannelIcon } from '../ChannelIcon'
11
+ import { ConversationRow } from '../ConversationRow'
12
+ import { CHANNEL_FILTER_ALL } from '../conversationChannel'
13
+ import { WINDOW_FILTERS } from '../conversationWindow'
14
+ import type { ConversationSummary } from '../providers/types'
15
+ import type { ConversationsWorkspaceLabels } from './labels'
16
+ import type { UseConversationsInboxResult } from './useConversationsInbox'
17
+ import { PAGINATION_LABELS } from '../pagination.constant'
18
+
19
+ export interface ConversationsInboxListProps {
20
+ readonly inbox: UseConversationsInboxResult
21
+ readonly labels: ConversationsWorkspaceLabels
22
+ readonly className?: string
23
+ /** Barra extra do produto acima da lista (ex.: filtro de carteira, seletor de campanha). */
24
+ readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
25
+ /** Ações em lote do produto, ao lado de finalizar e template. */
26
+ readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
27
+ readonly renderRow?: (conversation: ConversationSummary) => ReactNode
28
+ readonly onSendTemplateToSelected?: () => void
29
+ }
30
+
31
+ export function ConversationsInboxList({
32
+ inbox,
33
+ labels,
34
+ className,
35
+ renderFilters,
36
+ renderBulkActions,
37
+ renderRow,
38
+ onSendTemplateToSelected,
39
+ }: ConversationsInboxListProps) {
40
+ const hasSelection = inbox.selectedIds.size > 0
41
+
42
+ return (
43
+ <aside className={className}>
44
+ {hasSelection ? (
45
+ <div className="cv-workspace-bulk">
46
+ <span className="cv-workspace-bulk__count">{labels.bulkSelected(inbox.selectedIds.size)}</span>
47
+ <div className="cv-workspace-bulk__actions">
48
+ <button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={inbox.clearBulkSelection}>
49
+ {labels.bulkClear}
50
+ </button>
51
+ {onSendTemplateToSelected ? (
52
+ <button data-cv-tooltip={labels.bulkTemplate} aria-label={labels.bulkTemplate} type="button" onClick={onSendTemplateToSelected} disabled={inbox.busy}>
53
+ {labels.bulkTemplate}
54
+ </button>
55
+ ) : null}
56
+ {inbox.canFinalize ? (
57
+ <button
58
+ data-cv-tooltip={labels.bulkFinalize} aria-label={labels.bulkFinalize}
59
+ type="button"
60
+ disabled={inbox.busy}
61
+ onClick={() => {
62
+ // Confirmação porque finalizar em lote não tem desfazer: encerra o atendimento de
63
+ // todas as conversas marcadas de uma vez.
64
+ if (window.confirm(labels.bulkFinalizeConfirm(inbox.selectedIds.size))) {
65
+ void inbox.finalizeSelected()
66
+ }
67
+ }}
68
+ >
69
+ {labels.bulkFinalize}
70
+ </button>
71
+ ) : null}
72
+ {renderBulkActions?.(inbox)}
73
+ </div>
74
+ </div>
75
+ ) : null}
76
+
77
+ <div className="cv-workspace-filters">
78
+ <input
79
+ type="search"
80
+ value={inbox.search}
81
+ onChange={(event) => inbox.setSearch(event.target.value)}
82
+ placeholder={labels.search}
83
+ className="cv-workspace-search"
84
+ />
85
+
86
+ <div className="cv-workspace-chips">
87
+ <span className="cv-workspace-chips__legend">{labels.windowLegend}</span>
88
+ {WINDOW_FILTERS.map((filter) => (
89
+ <button
90
+ key={filter.value}
91
+ type="button"
92
+ onClick={() => inbox.setWindowFilter(filter.value)}
93
+ aria-pressed={inbox.windowFilter === filter.value}
94
+ data-cv-tooltip={filter.label} aria-label={filter.label}
95
+ className={[
96
+ 'cv-workspace-chip',
97
+ filter.tone ? `cv-workspace-chip--${filter.tone}` : '',
98
+ inbox.windowFilter === filter.value ? 'cv-workspace-chip--on' : '',
99
+ ]
100
+ .filter(Boolean)
101
+ .join(' ')}
102
+ >
103
+ {filter.label}
104
+ </button>
105
+ ))}
106
+ </div>
107
+
108
+ {/* Some quando há um canal só: filtro de opção única não filtra nada. */}
109
+ {inbox.channelFilters.length > 0 ? (
110
+ <div className="cv-workspace-chips">
111
+ <span className="cv-workspace-chips__legend">{labels.channelLegend}</span>
112
+ {inbox.channelFilters.map((filter) => (
113
+ <button
114
+ data-cv-tooltip={filter.label} aria-label={filter.label}
115
+ key={filter.value}
116
+ type="button"
117
+ onClick={() => inbox.setChannelFilter(filter.value)}
118
+ aria-pressed={inbox.channelFilter === filter.value}
119
+ className={`cv-workspace-chip${inbox.channelFilter === filter.value ? ' cv-workspace-chip--on' : ''}`}
120
+ >
121
+ {filter.value === CHANNEL_FILTER_ALL ? null : <ChannelIcon channel={filter.value} />}
122
+ {filter.label}
123
+ </button>
124
+ ))}
125
+ </div>
126
+ ) : null}
127
+
128
+ {renderFilters?.(inbox)}
129
+
130
+ <label className="cv-workspace-selectall">
131
+ <input type="checkbox" checked={inbox.allOnPageSelected} onChange={inbox.toggleSelectAllOnPage} />
132
+ {labels.selectAll}
133
+ </label>
134
+ </div>
135
+
136
+ <div className="cv-workspace-rows">
137
+ {inbox.pageConversations.map((conversation) =>
138
+ renderRow ? (
139
+ <div key={conversation.id}>{renderRow(conversation)}</div>
140
+ ) : (
141
+ <ConversationRow
142
+ key={conversation.id}
143
+ conversation={conversation}
144
+ active={conversation.id === inbox.selectedId}
145
+ selected={inbox.selectedIds.has(conversation.id)}
146
+ now={inbox.now}
147
+ busy={inbox.busy}
148
+ onOpen={() => inbox.selectConversation(conversation.id)}
149
+ onToggleSelected={() => inbox.toggleSelected(conversation.id)}
150
+ onTakeover={() => void inbox.takeover(conversation.id)}
151
+ />
152
+ ),
153
+ )}
154
+ {!inbox.loading && inbox.filteredCount === 0 ? (
155
+ <p className="cv-workspace-empty">{labels.emptyList}</p>
156
+ ) : null}
157
+ </div>
158
+
159
+ <div className="cv-workspace-pager">
160
+ <span>{labels.rangeOf(inbox.firstOnPage, inbox.lastOnPage, inbox.filteredCount)}</span>
161
+ <div className="cv-workspace-pager__buttons">
162
+ <button type="button" onClick={() => inbox.goToPage(1)} disabled={inbox.page === 1} aria-label={PAGINATION_LABELS.first} data-cv-tooltip={PAGINATION_LABELS.first}>
163
+ «
164
+ </button>
165
+ <button
166
+ type="button"
167
+ onClick={() => inbox.goToPage(inbox.page - 1)}
168
+ disabled={inbox.page === 1}
169
+ aria-label={PAGINATION_LABELS.previous} data-cv-tooltip={PAGINATION_LABELS.previous}
170
+ >
171
+
172
+ </button>
173
+ <span>{labels.pageOf(inbox.page, inbox.pageCount)}</span>
174
+ <button
175
+ type="button"
176
+ onClick={() => inbox.goToPage(inbox.page + 1)}
177
+ disabled={inbox.page === inbox.pageCount}
178
+ aria-label={PAGINATION_LABELS.next} data-cv-tooltip={PAGINATION_LABELS.next}
179
+ >
180
+
181
+ </button>
182
+ <button
183
+ type="button"
184
+ onClick={() => inbox.goToPage(inbox.pageCount)}
185
+ disabled={inbox.page === inbox.pageCount}
186
+ aria-label={PAGINATION_LABELS.last} data-cv-tooltip={PAGINATION_LABELS.last}
187
+ >
188
+ »
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </aside>
193
+ )
194
+ }