@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.
- package/dist/flows/index.d.ts +128 -2
- package/dist/flows/index.js +1085 -6
- package/dist/index.d.ts +255 -9
- package/dist/index.js +1309 -325
- package/dist/preview/index.d.ts +2 -2
- package/dist/styles.css +117 -0
- package/dist/{types-C6A_9edv.d.ts → types-BfINicc-.d.ts} +36 -1
- package/package.json +2 -2
- package/src/documents/DocumentsWorkspace.tsx +489 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +88 -0
- package/src/flows/FlowNodeCard.tsx +12 -3
- package/src/flows/FlowNodePanel.tsx +15 -2
- package/src/flows/FlowsWorkspace.tsx +1252 -0
- package/src/flows/index.ts +3 -1
- package/src/flows/labels.ts +132 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/index.ts +30 -0
- package/src/listing/index.tsx +198 -0
- package/src/providers/types.ts +36 -1
- package/src/settings/MessagesWorkspace.tsx +465 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +7 -1
- package/src/styles.css +108 -0
- package/src/workspace/ConversationPane.tsx +119 -4
- package/src/workspace/ConversationsWorkspace.tsx +33 -1
- package/src/workspace/labels.ts +5 -0
|
@@ -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
|
-
/**
|
|
73
|
-
|
|
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?: (
|
|
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 } : {})}
|
package/src/workspace/labels.ts
CHANGED
|
@@ -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',
|