@adatechnology/conversations-ui 0.1.0-rc.23 → 0.1.0-rc.24
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/index.d.ts +35 -5
- package/dist/index.js +78 -2
- package/dist/styles.css +35 -0
- package/package.json +1 -1
- package/src/styles.css +38 -0
- package/src/workspace/ConversationPane.tsx +119 -4
- package/src/workspace/ConversationsWorkspace.tsx +33 -1
- package/src/workspace/labels.ts +5 -0
package/dist/index.d.ts
CHANGED
|
@@ -1300,6 +1300,9 @@ interface ConversationsWorkspaceLabels {
|
|
|
1300
1300
|
readonly copySelected: string;
|
|
1301
1301
|
readonly composerPlaceholder: string;
|
|
1302
1302
|
readonly attachFailure: string;
|
|
1303
|
+
/** Tira um arquivo da fila antes de enviar. */
|
|
1304
|
+
readonly attachmentRemove: string;
|
|
1305
|
+
readonly recordFailure: string;
|
|
1303
1306
|
readonly sendFailure: string;
|
|
1304
1307
|
readonly takeoverToReply: string;
|
|
1305
1308
|
readonly signIn: string;
|
|
@@ -1428,20 +1431,28 @@ interface ConversationsWorkspaceProps {
|
|
|
1428
1431
|
readonly messageSelection?: boolean;
|
|
1429
1432
|
/** Texto já no campo ao abrir a conversa (deep link que sugere a resposta). */
|
|
1430
1433
|
readonly initialComposerText?: string | undefined;
|
|
1434
|
+
/** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
|
|
1435
|
+
readonly composer?: 'simple' | 'rich';
|
|
1436
|
+
/** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
|
|
1437
|
+
readonly composerVariablesFor?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => readonly RichComposerVariable[];
|
|
1438
|
+
/** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
|
|
1439
|
+
readonly onSendAttachments?: (conversation: ConversationSummary, files: readonly File[], caption: string) => Promise<void>;
|
|
1440
|
+
/** Nota de voz. Ausente, o microfone não aparece. */
|
|
1441
|
+
readonly onRecordAudio?: (conversation: ConversationSummary, file: File) => Promise<void>;
|
|
1431
1442
|
readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[];
|
|
1432
1443
|
readonly onAttach?: (conversation: ConversationSummary, file: File) => Promise<void>;
|
|
1433
1444
|
readonly extraUtilitiesFor?: (conversation: ConversationSummary) => readonly ConversationHeaderUtility[];
|
|
1434
1445
|
readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode;
|
|
1435
1446
|
readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode;
|
|
1436
1447
|
readonly renderRow?: (conversation: ConversationSummary) => ReactNode;
|
|
1437
|
-
readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode;
|
|
1448
|
+
readonly renderAboveTranscript?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => ReactNode;
|
|
1438
1449
|
readonly renderHeaderActions?: (inbox: UseConversationsInboxResult) => ReactNode;
|
|
1439
1450
|
readonly onSendTemplateToSelected?: (inbox: UseConversationsInboxResult) => void;
|
|
1440
1451
|
/** Destino do link de reentrar no painel, mostrado junto do aviso de sessão expirada. */
|
|
1441
1452
|
readonly signInHref?: string;
|
|
1442
1453
|
readonly className?: string;
|
|
1443
1454
|
}
|
|
1444
|
-
declare function ConversationsWorkspace({ labels: labelsOverride, filters, perPage, markReadOnOpen, serverPaginated, initialConversationId, initialWhatsappNumber, simulator, quickReplies, quickReplyVariablesFor, flowLabelOf, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, contextEntriesOf, onAttach, extraUtilitiesFor, renderFilters, renderBulkActions, renderRow, renderAboveTranscript, renderHeaderActions, onSendTemplateToSelected, signInHref, className, }: ConversationsWorkspaceProps): react.JSX.Element;
|
|
1455
|
+
declare function ConversationsWorkspace({ labels: labelsOverride, filters, perPage, markReadOnOpen, serverPaginated, initialConversationId, initialWhatsappNumber, simulator, quickReplies, quickReplyVariablesFor, flowLabelOf, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, composer, composerVariablesFor, onSendAttachments, onRecordAudio, contextEntriesOf, onAttach, extraUtilitiesFor, renderFilters, renderBulkActions, renderRow, renderAboveTranscript, renderHeaderActions, onSendTemplateToSelected, signInHref, className, }: ConversationsWorkspaceProps): react.JSX.Element;
|
|
1445
1456
|
|
|
1446
1457
|
interface ConversationPaneProps {
|
|
1447
1458
|
readonly conversation: ConversationSummary;
|
|
@@ -1480,11 +1491,30 @@ interface ConversationPaneProps {
|
|
|
1480
1491
|
readonly messageSelection?: boolean;
|
|
1481
1492
|
/** Texto já no campo ao abrir (deep link que sugere a resposta). */
|
|
1482
1493
|
readonly initialComposerText?: string | undefined;
|
|
1483
|
-
/**
|
|
1484
|
-
|
|
1494
|
+
/**
|
|
1495
|
+
* Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). Recebe o
|
|
1496
|
+
* contexto do fluxo junto: é dele que sai o resumo, e sem isso o host teria de buscá-lo de novo.
|
|
1497
|
+
*/
|
|
1498
|
+
readonly renderAboveTranscript?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => ReactNode;
|
|
1485
1499
|
readonly onAttach?: ((file: File) => Promise<void>) | undefined;
|
|
1500
|
+
/**
|
|
1501
|
+
* `rich` troca o `textarea` pelo campo com a formatação do WhatsApp desenhada enquanto se escreve.
|
|
1502
|
+
* Vale onde o atendente manda texto longo e formatado; para responder "já vou ver" o campo simples
|
|
1503
|
+
* é menos coisa na tela.
|
|
1504
|
+
*/
|
|
1505
|
+
readonly composer?: 'simple' | 'rich';
|
|
1506
|
+
/** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
|
|
1507
|
+
readonly composerVariablesFor?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => readonly RichComposerVariable[];
|
|
1508
|
+
/**
|
|
1509
|
+
* Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
|
|
1510
|
+
* barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
|
|
1511
|
+
* perde a legenda, e era a diferença entre as telas.
|
|
1512
|
+
*/
|
|
1513
|
+
readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>;
|
|
1514
|
+
/** Grava e envia nota de voz. Ausente, o microfone não aparece. */
|
|
1515
|
+
readonly onRecordAudio?: (file: File) => Promise<void>;
|
|
1486
1516
|
}
|
|
1487
|
-
declare function ConversationPane({ conversation, now, busy, labels, onTakeover, onReturnToBot, onFinish, onBack, extraUtilities, contextEntriesOf, quickReplies, quickReplyVariablesFor, flowLabel, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, renderAboveTranscript, onAttach, }: ConversationPaneProps): react.JSX.Element;
|
|
1517
|
+
declare function ConversationPane({ conversation, now, busy, labels, onTakeover, onReturnToBot, onFinish, onBack, extraUtilities, contextEntriesOf, quickReplies, quickReplyVariablesFor, flowLabel, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, renderAboveTranscript, onAttach, composer, composerVariablesFor, onSendAttachments, onRecordAudio, }: ConversationPaneProps): react.JSX.Element;
|
|
1488
1518
|
|
|
1489
1519
|
interface ConversationsInboxListProps {
|
|
1490
1520
|
readonly inbox: UseConversationsInboxResult;
|
package/dist/index.js
CHANGED
|
@@ -2699,7 +2699,11 @@ function ConversationPane({
|
|
|
2699
2699
|
messageSelection,
|
|
2700
2700
|
initialComposerText,
|
|
2701
2701
|
renderAboveTranscript,
|
|
2702
|
-
onAttach
|
|
2702
|
+
onAttach,
|
|
2703
|
+
composer = "simple",
|
|
2704
|
+
composerVariablesFor,
|
|
2705
|
+
onSendAttachments,
|
|
2706
|
+
onRecordAudio
|
|
2703
2707
|
}) {
|
|
2704
2708
|
const context = useConversations();
|
|
2705
2709
|
if (!context) {
|
|
@@ -2712,9 +2716,11 @@ function ConversationPane({
|
|
|
2712
2716
|
const [sendFailure, setSendFailure] = useState12(void 0);
|
|
2713
2717
|
const [selectedMessageIds, setSelectedMessageIds] = useState12(/* @__PURE__ */ new Set());
|
|
2714
2718
|
const [draft, setDraft] = useState12(initialComposerText ?? "");
|
|
2719
|
+
const [queuedFiles, setQueuedFiles] = useState12([]);
|
|
2715
2720
|
useEffect8(() => {
|
|
2716
2721
|
setSelectedMessageIds(/* @__PURE__ */ new Set());
|
|
2717
2722
|
setDraft(initialComposerText ?? "");
|
|
2723
|
+
setQueuedFiles([]);
|
|
2718
2724
|
}, [conversation.id, initialComposerText]);
|
|
2719
2725
|
function toggleMessageSelected(messageId) {
|
|
2720
2726
|
setSelectedMessageIds((current) => {
|
|
@@ -2750,6 +2756,19 @@ function ConversationPane({
|
|
|
2750
2756
|
async function handleSend(text) {
|
|
2751
2757
|
await runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure);
|
|
2752
2758
|
}
|
|
2759
|
+
async function handleRichSend() {
|
|
2760
|
+
if (onSendAttachments && queuedFiles.length > 0) {
|
|
2761
|
+
const files = queuedFiles;
|
|
2762
|
+
const caption = draft;
|
|
2763
|
+
await runSend(() => onSendAttachments(files, caption), labels.attachFailure);
|
|
2764
|
+
setQueuedFiles([]);
|
|
2765
|
+
setDraft("");
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
if (!draft.trim()) return;
|
|
2769
|
+
await handleSend(draft);
|
|
2770
|
+
setDraft("");
|
|
2771
|
+
}
|
|
2753
2772
|
async function handleAttach(file) {
|
|
2754
2773
|
if (!onAttach) return;
|
|
2755
2774
|
await runSend(() => onAttach(file), labels.attachFailure);
|
|
@@ -2765,6 +2784,12 @@ function ConversationPane({
|
|
|
2765
2784
|
);
|
|
2766
2785
|
}
|
|
2767
2786
|
const contextEntries = contextEntriesOf?.(conversationContext);
|
|
2787
|
+
const composerVariables = composerVariablesFor?.(conversation, conversationContext);
|
|
2788
|
+
const richQuickReplies = quickReplies?.map((reply) => ({
|
|
2789
|
+
id: reply.key,
|
|
2790
|
+
label: reply.label,
|
|
2791
|
+
text: typeof reply.text === "string" ? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {}) : reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
|
|
2792
|
+
}));
|
|
2768
2793
|
const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== "human";
|
|
2769
2794
|
return /* @__PURE__ */ jsxs20("div", { className: "cv-workspace-pane", children: [
|
|
2770
2795
|
/* @__PURE__ */ jsx22(
|
|
@@ -2784,7 +2809,7 @@ function ConversationPane({
|
|
|
2784
2809
|
),
|
|
2785
2810
|
contextEntries && contextEntries.length > 0 || flowLabel ? /* @__PURE__ */ jsx22(ConversationContextPanel, { entries: contextEntries ?? [], ...flowLabel ? { flowLabel } : {} }) : null,
|
|
2786
2811
|
/* @__PURE__ */ jsx22(ConversationDocumentsPanel, { conversationId: conversation.id, open: documentsOpen }),
|
|
2787
|
-
renderAboveTranscript?.(conversation),
|
|
2812
|
+
renderAboveTranscript?.(conversation, conversationContext),
|
|
2788
2813
|
/* @__PURE__ */ jsx22(
|
|
2789
2814
|
ConversationWallpaper,
|
|
2790
2815
|
{
|
|
@@ -2830,6 +2855,45 @@ function ConversationPane({
|
|
|
2830
2855
|
) : botOwnsConversation ? (
|
|
2831
2856
|
// Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
|
|
2832
2857
|
/* @__PURE__ */ jsx22("p", { className: "cv-workspace-notice", children: labels.takeoverToReply })
|
|
2858
|
+
) : composer === "rich" ? /* @__PURE__ */ jsx22(
|
|
2859
|
+
RichMessageComposer,
|
|
2860
|
+
{
|
|
2861
|
+
value: draft,
|
|
2862
|
+
onChange: setDraft,
|
|
2863
|
+
onSend: () => void handleRichSend(),
|
|
2864
|
+
...onSendAttachments ? { onAttachFiles: (files) => setQueuedFiles((current) => [...current, ...Array.from(files)]) } : onAttach ? {
|
|
2865
|
+
onAttachFiles: (files) => {
|
|
2866
|
+
for (const file of Array.from(files)) void handleAttach(file);
|
|
2867
|
+
}
|
|
2868
|
+
} : {},
|
|
2869
|
+
placeholder: labels.composerPlaceholder,
|
|
2870
|
+
isSending: busy,
|
|
2871
|
+
...onRecordAudio ? {
|
|
2872
|
+
idleAction: /* @__PURE__ */ jsx22(
|
|
2873
|
+
AudioRecorderButton,
|
|
2874
|
+
{
|
|
2875
|
+
onRecorded: (file) => void runSend(() => onRecordAudio(file), labels.recordFailure),
|
|
2876
|
+
onFailure: (failureMessage) => setSendFailure(failureMessage)
|
|
2877
|
+
}
|
|
2878
|
+
)
|
|
2879
|
+
} : {},
|
|
2880
|
+
...queuedFiles.length > 0 ? {
|
|
2881
|
+
attachmentsPreview: /* @__PURE__ */ jsx22("ul", { className: "cv-workspace-attachments", children: queuedFiles.map((file, index) => /* @__PURE__ */ jsxs20("li", { children: [
|
|
2882
|
+
/* @__PURE__ */ jsx22("span", { children: file.name }),
|
|
2883
|
+
/* @__PURE__ */ jsx22(
|
|
2884
|
+
"button",
|
|
2885
|
+
{
|
|
2886
|
+
type: "button",
|
|
2887
|
+
"aria-label": labels.attachmentRemove,
|
|
2888
|
+
onClick: () => setQueuedFiles((current) => current.filter((_, position) => position !== index)),
|
|
2889
|
+
children: "\u2715"
|
|
2890
|
+
}
|
|
2891
|
+
)
|
|
2892
|
+
] }, `${file.name}-${index}`)) })
|
|
2893
|
+
} : {},
|
|
2894
|
+
...richQuickReplies ? { quickReplies: [...richQuickReplies] } : {},
|
|
2895
|
+
...composerVariables ? { variables: [...composerVariables] } : {}
|
|
2896
|
+
}
|
|
2833
2897
|
) : /* @__PURE__ */ jsx22(
|
|
2834
2898
|
MessageComposer,
|
|
2835
2899
|
{
|
|
@@ -3021,6 +3085,8 @@ var DEFAULT_CONVERSATIONS_WORKSPACE_LABELS = {
|
|
|
3021
3085
|
copySelected: "Copiar",
|
|
3022
3086
|
composerPlaceholder: "Responder como atendente\u2026",
|
|
3023
3087
|
attachFailure: "Falha ao enviar o arquivo.",
|
|
3088
|
+
attachmentRemove: "Remover anexo",
|
|
3089
|
+
recordFailure: "Falha ao gravar o \xE1udio.",
|
|
3024
3090
|
sendFailure: "Falha ao enviar a mensagem.",
|
|
3025
3091
|
takeoverToReply: "Assuma o atendimento para responder diretamente ao cliente.",
|
|
3026
3092
|
signIn: "Entrar no painel",
|
|
@@ -3225,6 +3291,10 @@ function ConversationsWorkspace({
|
|
|
3225
3291
|
requireTakeoverToReply,
|
|
3226
3292
|
messageSelection,
|
|
3227
3293
|
initialComposerText,
|
|
3294
|
+
composer,
|
|
3295
|
+
composerVariablesFor,
|
|
3296
|
+
onSendAttachments,
|
|
3297
|
+
onRecordAudio,
|
|
3228
3298
|
contextEntriesOf,
|
|
3229
3299
|
onAttach,
|
|
3230
3300
|
extraUtilitiesFor,
|
|
@@ -3403,6 +3473,12 @@ function ConversationsWorkspace({
|
|
|
3403
3473
|
...requireTakeoverToReply ? { requireTakeoverToReply } : {},
|
|
3404
3474
|
...messageSelection ? { messageSelection } : {},
|
|
3405
3475
|
...initialComposerText ? { initialComposerText } : {},
|
|
3476
|
+
...composer ? { composer } : {},
|
|
3477
|
+
...composerVariablesFor ? { composerVariablesFor } : {},
|
|
3478
|
+
...onSendAttachments ? {
|
|
3479
|
+
onSendAttachments: (files, caption) => onSendAttachments(selected, files, caption)
|
|
3480
|
+
} : {},
|
|
3481
|
+
...onRecordAudio ? { onRecordAudio: (file) => onRecordAudio(selected, file) } : {},
|
|
3406
3482
|
onBack: inbox.clearSelection,
|
|
3407
3483
|
...paneUtilities ? { extraUtilities: paneUtilities } : {},
|
|
3408
3484
|
...contextEntriesOf ? { contextEntriesOf } : {},
|
package/dist/styles.css
CHANGED
|
@@ -796,3 +796,38 @@
|
|
|
796
796
|
border-color: #4b5563;
|
|
797
797
|
color: #f3f4f6;
|
|
798
798
|
}
|
|
799
|
+
.cv-workspace-attachments {
|
|
800
|
+
display: flex;
|
|
801
|
+
flex-wrap: wrap;
|
|
802
|
+
gap: 0.5rem;
|
|
803
|
+
margin: 0 0 0.5rem;
|
|
804
|
+
padding: 0;
|
|
805
|
+
list-style: none;
|
|
806
|
+
}
|
|
807
|
+
.cv-workspace-attachments li {
|
|
808
|
+
display: flex;
|
|
809
|
+
align-items: center;
|
|
810
|
+
gap: 0.5rem;
|
|
811
|
+
max-width: 14rem;
|
|
812
|
+
padding: 0.25rem 0.5rem;
|
|
813
|
+
border: 1px solid rgb(226 232 240);
|
|
814
|
+
border-radius: 0.5rem;
|
|
815
|
+
font-size: 0.75rem;
|
|
816
|
+
}
|
|
817
|
+
.cv-workspace-attachments li span {
|
|
818
|
+
overflow: hidden;
|
|
819
|
+
text-overflow: ellipsis;
|
|
820
|
+
white-space: nowrap;
|
|
821
|
+
}
|
|
822
|
+
.cv-workspace-attachments li button {
|
|
823
|
+
flex: none;
|
|
824
|
+
min-width: 1.5rem;
|
|
825
|
+
min-height: 1.5rem;
|
|
826
|
+
cursor: pointer;
|
|
827
|
+
background: none;
|
|
828
|
+
border: 0;
|
|
829
|
+
color: inherit;
|
|
830
|
+
}
|
|
831
|
+
.dark .cv-workspace-attachments li {
|
|
832
|
+
border-color: rgb(51 65 85);
|
|
833
|
+
}
|
package/package.json
CHANGED
package/src/styles.css
CHANGED
|
@@ -734,3 +734,41 @@
|
|
|
734
734
|
border-color: #4b5563;
|
|
735
735
|
color: #f3f4f6;
|
|
736
736
|
}
|
|
737
|
+
|
|
738
|
+
/* Fila de anexos acima da barra: nomes visíveis para conferir antes de mandar, e um X por item —
|
|
739
|
+
escolher seis arquivos e não poder tirar o errado obrigava a recomeçar. */
|
|
740
|
+
.cv-workspace-attachments {
|
|
741
|
+
display: flex;
|
|
742
|
+
flex-wrap: wrap;
|
|
743
|
+
gap: 0.5rem;
|
|
744
|
+
margin: 0 0 0.5rem;
|
|
745
|
+
padding: 0;
|
|
746
|
+
list-style: none;
|
|
747
|
+
}
|
|
748
|
+
.cv-workspace-attachments li {
|
|
749
|
+
display: flex;
|
|
750
|
+
align-items: center;
|
|
751
|
+
gap: 0.5rem;
|
|
752
|
+
max-width: 14rem;
|
|
753
|
+
padding: 0.25rem 0.5rem;
|
|
754
|
+
border: 1px solid rgb(226 232 240);
|
|
755
|
+
border-radius: 0.5rem;
|
|
756
|
+
font-size: 0.75rem;
|
|
757
|
+
}
|
|
758
|
+
.cv-workspace-attachments li span {
|
|
759
|
+
overflow: hidden;
|
|
760
|
+
text-overflow: ellipsis;
|
|
761
|
+
white-space: nowrap;
|
|
762
|
+
}
|
|
763
|
+
.cv-workspace-attachments li button {
|
|
764
|
+
flex: none;
|
|
765
|
+
min-width: 1.5rem;
|
|
766
|
+
min-height: 1.5rem;
|
|
767
|
+
cursor: pointer;
|
|
768
|
+
background: none;
|
|
769
|
+
border: 0;
|
|
770
|
+
color: inherit;
|
|
771
|
+
}
|
|
772
|
+
.dark .cv-workspace-attachments li {
|
|
773
|
+
border-color: rgb(51 65 85);
|
|
774
|
+
}
|
|
@@ -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',
|