@adatechnology/conversations-ui 0.1.0-rc.23 → 0.1.0-rc.25

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.
@@ -7,13 +7,15 @@
7
7
 
8
8
  import { useEffect, useState, type ReactNode } from 'react'
9
9
 
10
+ import { AudioRecorderButton } from '../AudioRecorderButton'
10
11
  import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
11
12
  import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
12
13
  import { ConversationHeader, type ConversationHeaderUtility } from '../ConversationHeader'
13
14
  import { ConversationWallpaper } from '../Wallpaper'
14
15
  import { DateDivider } from '../DateDivider'
15
16
  import { MessageBubble } from '../MessageBubble'
16
- import { MessageComposer, type QuickReply } from '../MessageComposer'
17
+ import { MessageComposer, applyQuickReplyVariables, type QuickReply } from '../MessageComposer'
18
+ import { RichMessageComposer, type RichComposerVariable } from '../RichMessageComposer'
17
19
  import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
18
20
  import { windowOf } from '../conversationWindow'
19
21
  import {
@@ -69,9 +71,34 @@ export interface ConversationPaneProps {
69
71
  readonly messageSelection?: boolean
70
72
  /** Texto já no campo ao abrir (deep link que sugere a resposta). */
71
73
  readonly initialComposerText?: string | undefined
72
- /** Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). */
73
- readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode
74
+ /**
75
+ * Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). Recebe o
76
+ * contexto do fluxo junto: é dele que sai o resumo, e sem isso o host teria de buscá-lo de novo.
77
+ */
78
+ readonly renderAboveTranscript?: (
79
+ conversation: ConversationSummary,
80
+ context: Record<string, unknown> | undefined,
81
+ ) => ReactNode
74
82
  readonly onAttach?: ((file: File) => Promise<void>) | undefined
83
+ /**
84
+ * `rich` troca o `textarea` pelo campo com a formatação do WhatsApp desenhada enquanto se escreve.
85
+ * Vale onde o atendente manda texto longo e formatado; para responder "já vou ver" o campo simples
86
+ * é menos coisa na tela.
87
+ */
88
+ readonly composer?: 'simple' | 'rich'
89
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
90
+ readonly composerVariablesFor?: (
91
+ conversation: ConversationSummary,
92
+ context: Record<string, unknown> | undefined,
93
+ ) => readonly RichComposerVariable[]
94
+ /**
95
+ * Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
96
+ * barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
97
+ * perde a legenda, e era a diferença entre as telas.
98
+ */
99
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
100
+ /** Grava e envia nota de voz. Ausente, o microfone não aparece. */
101
+ readonly onRecordAudio?: (file: File) => Promise<void>
75
102
  }
76
103
 
77
104
  export function ConversationPane({
@@ -94,6 +121,10 @@ export function ConversationPane({
94
121
  initialComposerText,
95
122
  renderAboveTranscript,
96
123
  onAttach,
124
+ composer = 'simple',
125
+ composerVariablesFor,
126
+ onSendAttachments,
127
+ onRecordAudio,
97
128
  }: ConversationPaneProps) {
98
129
  const context = useConversations()
99
130
  if (!context) {
@@ -107,12 +138,14 @@ export function ConversationPane({
107
138
  const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
108
139
  const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
109
140
  const [draft, setDraft] = useState(initialComposerText ?? '')
141
+ const [queuedFiles, setQueuedFiles] = useState<readonly File[]>([])
110
142
 
111
143
  // Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
112
144
  // levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
113
145
  useEffect(() => {
114
146
  setSelectedMessageIds(new Set())
115
147
  setDraft(initialComposerText ?? '')
148
+ setQueuedFiles([])
116
149
  }, [conversation.id, initialComposerText])
117
150
 
118
151
  function toggleMessageSelected(messageId: string): void {
@@ -168,6 +201,24 @@ export function ConversationPane({
168
201
  await runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure)
169
202
  }
170
203
 
204
+ /**
205
+ * O texto escrito é a legenda do anexo, não uma segunda mensagem: no WhatsApp a foto chega com a
206
+ * frase embaixo, e mandar as duas separadas invertia a ordem quando a mídia demorava a subir.
207
+ */
208
+ async function handleRichSend(): Promise<void> {
209
+ if (onSendAttachments && queuedFiles.length > 0) {
210
+ const files = queuedFiles
211
+ const caption = draft
212
+ await runSend(() => onSendAttachments(files, caption), labels.attachFailure)
213
+ setQueuedFiles([])
214
+ setDraft('')
215
+ return
216
+ }
217
+ if (!draft.trim()) return
218
+ await handleSend(draft)
219
+ setDraft('')
220
+ }
221
+
171
222
  async function handleAttach(file: File): Promise<void> {
172
223
  if (!onAttach) return
173
224
  await runSend(() => onAttach(file), labels.attachFailure)
@@ -185,6 +236,19 @@ export function ConversationPane({
185
236
  }
186
237
 
187
238
  const contextEntries = contextEntriesOf?.(conversationContext)
239
+ const composerVariables = composerVariablesFor?.(conversation, conversationContext)
240
+ /**
241
+ * As mesmas `quickReplies` do composer simples, com as variáveis já resolvidas — o campo rico
242
+ * recebe texto pronto. Uma segunda lista, só de formato diferente, é como as telas divergiam.
243
+ */
244
+ const richQuickReplies = quickReplies?.map((reply) => ({
245
+ id: reply.key,
246
+ label: reply.label,
247
+ text:
248
+ typeof reply.text === 'string'
249
+ ? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
250
+ : reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {}),
251
+ }))
188
252
  const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
189
253
 
190
254
  return (
@@ -207,7 +271,7 @@ export function ConversationPane({
207
271
  ) : null}
208
272
  <ConversationDocumentsPanel conversationId={conversation.id} open={documentsOpen} />
209
273
 
210
- {renderAboveTranscript?.(conversation)}
274
+ {renderAboveTranscript?.(conversation, conversationContext)}
211
275
 
212
276
  {/* Mesmo wallpaper do preview do cliente: atendente e cliente devem ver a conversa com a
213
277
  mesma aparência, senão o preview deixa de ser referência confiável. */}
@@ -269,6 +333,57 @@ export function ConversationPane({
269
333
  ) : botOwnsConversation ? (
270
334
  // Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
271
335
  <p className="cv-workspace-notice">{labels.takeoverToReply}</p>
336
+ ) : composer === 'rich' ? (
337
+ <RichMessageComposer
338
+ value={draft}
339
+ onChange={setDraft}
340
+ onSend={() => void handleRichSend()}
341
+ {...(onSendAttachments
342
+ ? { onAttachFiles: (files: FileList) => setQueuedFiles((current) => [...current, ...Array.from(files)]) }
343
+ : onAttach
344
+ ? {
345
+ onAttachFiles: (files: FileList) => {
346
+ for (const file of Array.from(files)) void handleAttach(file)
347
+ },
348
+ }
349
+ : {})}
350
+ placeholder={labels.composerPlaceholder}
351
+ isSending={busy}
352
+ {...(onRecordAudio
353
+ ? {
354
+ idleAction: (
355
+ <AudioRecorderButton
356
+ onRecorded={(file) => void runSend(() => onRecordAudio(file), labels.recordFailure)}
357
+ onFailure={(failureMessage) => setSendFailure(failureMessage)}
358
+ />
359
+ ),
360
+ }
361
+ : {})}
362
+ {...(queuedFiles.length > 0
363
+ ? {
364
+ attachmentsPreview: (
365
+ <ul className="cv-workspace-attachments">
366
+ {queuedFiles.map((file, index) => (
367
+ <li key={`${file.name}-${index}`}>
368
+ <span>{file.name}</span>
369
+ <button
370
+ type="button"
371
+ aria-label={labels.attachmentRemove}
372
+ onClick={() =>
373
+ setQueuedFiles((current) => current.filter((_, position) => position !== index))
374
+ }
375
+ >
376
+ ✕
377
+ </button>
378
+ </li>
379
+ ))}
380
+ </ul>
381
+ ),
382
+ }
383
+ : {})}
384
+ {...(richQuickReplies ? { quickReplies: [...richQuickReplies] } : {})}
385
+ {...(composerVariables ? { variables: [...composerVariables] } : {})}
386
+ />
272
387
  ) : (
273
388
  <MessageComposer
274
389
  value={draft}
@@ -16,6 +16,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'
16
16
  import type { ConversationContextEntry } from '../ConversationContextPanel'
17
17
  import type { ConversationHeaderUtility } from '../ConversationHeader'
18
18
  import type { QuickReply } from '../MessageComposer'
19
+ import type { RichComposerVariable } from '../RichMessageComposer'
19
20
  import type { ConversationSummary } from '../providers/types'
20
21
  import { BulkTemplateModal } from './BulkTemplateModal'
21
22
  import { ConversationPane } from './ConversationPane'
@@ -62,13 +63,31 @@ export interface ConversationsWorkspaceProps {
62
63
  readonly messageSelection?: boolean
63
64
  /** Texto já no campo ao abrir a conversa (deep link que sugere a resposta). */
64
65
  readonly initialComposerText?: string | undefined
66
+ /** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
67
+ readonly composer?: 'simple' | 'rich'
68
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
69
+ readonly composerVariablesFor?: (
70
+ conversation: ConversationSummary,
71
+ context: Record<string, unknown> | undefined,
72
+ ) => readonly RichComposerVariable[]
73
+ /** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
74
+ readonly onSendAttachments?: (
75
+ conversation: ConversationSummary,
76
+ files: readonly File[],
77
+ caption: string,
78
+ ) => Promise<void>
79
+ /** Nota de voz. Ausente, o microfone não aparece. */
80
+ readonly onRecordAudio?: (conversation: ConversationSummary, file: File) => Promise<void>
65
81
  readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
66
82
  readonly onAttach?: (conversation: ConversationSummary, file: File) => Promise<void>
67
83
  readonly extraUtilitiesFor?: (conversation: ConversationSummary) => readonly ConversationHeaderUtility[]
68
84
  readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
69
85
  readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
70
86
  readonly renderRow?: (conversation: ConversationSummary) => ReactNode
71
- readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode
87
+ readonly renderAboveTranscript?: (
88
+ conversation: ConversationSummary,
89
+ context: Record<string, unknown> | undefined,
90
+ ) => ReactNode
72
91
  readonly renderHeaderActions?: (inbox: UseConversationsInboxResult) => ReactNode
73
92
  readonly onSendTemplateToSelected?: (inbox: UseConversationsInboxResult) => void
74
93
  /** Destino do link de reentrar no painel, mostrado junto do aviso de sessão expirada. */
@@ -92,6 +111,10 @@ export function ConversationsWorkspace({
92
111
  requireTakeoverToReply,
93
112
  messageSelection,
94
113
  initialComposerText,
114
+ composer,
115
+ composerVariablesFor,
116
+ onSendAttachments,
117
+ onRecordAudio,
95
118
  contextEntriesOf,
96
119
  onAttach,
97
120
  extraUtilitiesFor,
@@ -268,6 +291,15 @@ export function ConversationsWorkspace({
268
291
  {...(requireTakeoverToReply ? { requireTakeoverToReply } : {})}
269
292
  {...(messageSelection ? { messageSelection } : {})}
270
293
  {...(initialComposerText ? { initialComposerText } : {})}
294
+ {...(composer ? { composer } : {})}
295
+ {...(composerVariablesFor ? { composerVariablesFor } : {})}
296
+ {...(onSendAttachments
297
+ ? {
298
+ onSendAttachments: (files: readonly File[], caption: string) =>
299
+ onSendAttachments(selected, files, caption),
300
+ }
301
+ : {})}
302
+ {...(onRecordAudio ? { onRecordAudio: (file: File) => onRecordAudio(selected, file) } : {})}
271
303
  onBack={inbox.clearSelection}
272
304
  {...(paneUtilities ? { extraUtilities: paneUtilities } : {})}
273
305
  {...(contextEntriesOf ? { contextEntriesOf } : {})}
@@ -34,6 +34,9 @@ export interface ConversationsWorkspaceLabels {
34
34
  readonly copySelected: string
35
35
  readonly composerPlaceholder: string
36
36
  readonly attachFailure: string
37
+ /** Tira um arquivo da fila antes de enviar. */
38
+ readonly attachmentRemove: string
39
+ readonly recordFailure: string
37
40
  readonly sendFailure: string
38
41
  readonly takeoverToReply: string
39
42
  readonly signIn: string
@@ -72,6 +75,8 @@ export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabel
72
75
  copySelected: 'Copiar',
73
76
  composerPlaceholder: 'Responder como atendente…',
74
77
  attachFailure: 'Falha ao enviar o arquivo.',
78
+ attachmentRemove: 'Remover anexo',
79
+ recordFailure: 'Falha ao gravar o áudio.',
75
80
  sendFailure: 'Falha ao enviar a mensagem.',
76
81
  takeoverToReply: 'Assuma o atendimento para responder diretamente ao cliente.',
77
82
  signIn: 'Entrar no painel',