@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.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2389 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1219 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +225 -17
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tela de atendimento inteira: cabeçalho com contadores, lista, conversa e simulador.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque o pacote exportava só as peças, e cada produto montava a própria grade — foi assim
|
|
5
|
+
* que a mesma inbox ganhou três larguras de coluna, dois comportamentos em tela estreita e um lugar
|
|
6
|
+
* diferente para o botão do simulador. A composição é a parte que precisa ser idêntica; o que muda
|
|
7
|
+
* de produto entra pelos slots, não por uma cópia da tela.
|
|
8
|
+
*
|
|
9
|
+
* Customização: `labels` (texto e idioma), `renderFilters` / `renderBulkActions` /
|
|
10
|
+
* `renderAboveTranscript` / `renderRow` (peças do produto), `contextEntriesOf` (vocabulário do
|
|
11
|
+
* contexto), `extraUtilities` (ações no cabeçalho da conversa) e `simulator` (painel de preview).
|
|
12
|
+
*
|
|
13
|
+
* O simulador é montado aqui, e não pelo host: com `simulator.transports` o produto entrega só o
|
|
14
|
+
* transporte de cada canal e a moldura vem do pacote. Isso traz o `preview/` para o bundle de quem
|
|
15
|
+
* usa o workspace — o preço de a tela composta ser o padrão de consumo, que é o que impede a inbox
|
|
16
|
+
* de divergir entre produtos.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
20
|
+
import { Check, CheckCheck, FlaskConical, Hourglass, Mail, MessagesSquare } from 'lucide-react'
|
|
21
|
+
|
|
22
|
+
import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from '../icon.constant'
|
|
23
|
+
import type { ConversationContextEntry } from '../ConversationContextPanel'
|
|
24
|
+
import type { ConversationHeaderUtility } from '../ConversationHeader'
|
|
25
|
+
import type { QuickReply } from '../MessageComposer'
|
|
26
|
+
import type { RichComposerVariable } from '../RichMessageComposer'
|
|
27
|
+
import type { ConversationSummary } from '../providers/types'
|
|
28
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
29
|
+
import { DEFAULT_CONVERSATION_CHANNEL, formatContactHandle, type ConversationChannel } from '../conversationChannel'
|
|
30
|
+
import type { ConversationSimulatorClient } from '../preview/ConversationSimulatorClient'
|
|
31
|
+
import type { PreviewUploadedMedia } from '../preview/createPreviewMediaUploader'
|
|
32
|
+
import {
|
|
33
|
+
ConversationSimulatorPanel,
|
|
34
|
+
type ConversationSimulatorPanelLabels,
|
|
35
|
+
} from '../preview/ConversationSimulatorPanel'
|
|
36
|
+
import { BulkTemplateModal } from './BulkTemplateModal'
|
|
37
|
+
import { ConversationPane } from './ConversationPane'
|
|
38
|
+
import { ConversationsInboxList } from './ConversationsInboxList'
|
|
39
|
+
import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabels } from './labels'
|
|
40
|
+
import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
|
|
41
|
+
import { TooltipLayer } from '../Tooltip'
|
|
42
|
+
|
|
43
|
+
export type SimulatorTransportParams = {
|
|
44
|
+
readonly conversationId: string
|
|
45
|
+
readonly channel: ConversationChannel
|
|
46
|
+
/** Identificador do contato no canal: telefone no WhatsApp, id de sessão no chat do site. */
|
|
47
|
+
readonly handle: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fábrica do transporte daquele canal.
|
|
52
|
+
*
|
|
53
|
+
* É o único ponto onde o host precisa saber de canal: o painel, a moldura e o comportamento são os
|
|
54
|
+
* mesmos em todos. No WhatsApp devolve o cliente-ponte (assinatura no servidor do host); no chat do
|
|
55
|
+
* site, o cliente das rotas do widget.
|
|
56
|
+
*/
|
|
57
|
+
export type SimulatorTransportFactory = (params: SimulatorTransportParams) => ConversationSimulatorClient
|
|
58
|
+
|
|
59
|
+
export interface ConversationsWorkspaceSimulator {
|
|
60
|
+
/**
|
|
61
|
+
* Um transporte por canal — o workspace monta o painel com o da conversa selecionada.
|
|
62
|
+
*
|
|
63
|
+
* Canal sem transporte não desenha o botão: capacidade é opcional por ausência, e oferecer
|
|
64
|
+
* "simular" numa conversa que não tem como receber a mensagem é um botão que falha ao ser tocado.
|
|
65
|
+
*/
|
|
66
|
+
readonly transports?: Partial<Record<ConversationChannel, SimulatorTransportFactory>>
|
|
67
|
+
/**
|
|
68
|
+
* Válvula de escape: o host desenha o painel inteiro. Tem precedência sobre `transports`.
|
|
69
|
+
*
|
|
70
|
+
* Era a única porta antes de `transports`, quando o simulador só falava WhatsApp e cada produto
|
|
71
|
+
* remontava a moldura — o que fez duas telas da mesma casa divergirem. Continua aceito para não
|
|
72
|
+
* quebrar quem já a usa.
|
|
73
|
+
*/
|
|
74
|
+
render?(params: { conversationId: string; channel: ConversationChannel; close: () => void }): ReactNode
|
|
75
|
+
/** Ausente = ligado. Serve para esconder fora de desenvolvimento sem condicionar o JSX. */
|
|
76
|
+
readonly enabled?: boolean
|
|
77
|
+
/** Ícone da biblioteca (lucide) no utilitário do cabeçalho. Ausente, entra o frasco de teste. */
|
|
78
|
+
readonly icon?: ReactNode
|
|
79
|
+
readonly label?: string
|
|
80
|
+
/** Vocabulário do painel. O que muda de canal (destino, placeholder) já vem resolvido. */
|
|
81
|
+
readonly labels?: Partial<ConversationSimulatorPanelLabels>
|
|
82
|
+
/** Destino do upload no canal que entrega mídia por referência (o caminho da Meta). */
|
|
83
|
+
readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
84
|
+
/** Recarrega o transcript a cada N ms. Serve a host sem stream. */
|
|
85
|
+
readonly pollIntervalMs?: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ConversationsWorkspaceProps {
|
|
89
|
+
readonly labels?: Partial<ConversationsWorkspaceLabels>
|
|
90
|
+
readonly filters?: Record<string, string | undefined>
|
|
91
|
+
readonly perPage?: number
|
|
92
|
+
readonly markReadOnOpen?: boolean
|
|
93
|
+
/** Pede a página ao servidor em vez de fatiar no cliente. */
|
|
94
|
+
readonly serverPaginated?: boolean
|
|
95
|
+
/** Conversa a abrir na montagem (deep link `?id=`). */
|
|
96
|
+
readonly initialConversationId?: string | undefined
|
|
97
|
+
/** Idem, pelo telefone — é o que costuma vir no link de um alerta ou de um pedido. */
|
|
98
|
+
readonly initialWhatsappNumber?: string | undefined
|
|
99
|
+
readonly simulator?: ConversationsWorkspaceSimulator
|
|
100
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
101
|
+
readonly quickReplyVariablesFor?: (
|
|
102
|
+
conversation: ConversationSummary,
|
|
103
|
+
context: Record<string, unknown> | undefined,
|
|
104
|
+
) => Record<string, string>
|
|
105
|
+
/** Etapa do fluxo mostrada no painel de contexto. */
|
|
106
|
+
readonly flowLabelOf?: (conversation: ConversationSummary) => string | undefined
|
|
107
|
+
/** Substitui o download local do transcript (ex.: exportação completa pela rota do servidor). */
|
|
108
|
+
readonly onDownload?: (conversation: ConversationSummary) => void
|
|
109
|
+
/** Bloqueia o composer enquanto a conversa estiver com o bot. */
|
|
110
|
+
readonly requireTakeoverToReply?: boolean
|
|
111
|
+
/** Deixa marcar mensagens no transcript e copiar o trecho. */
|
|
112
|
+
readonly messageSelection?: boolean
|
|
113
|
+
/** Texto já no campo ao abrir a conversa (deep link que sugere a resposta). */
|
|
114
|
+
readonly initialComposerText?: string | undefined
|
|
115
|
+
/** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
|
|
116
|
+
readonly composer?: 'simple' | 'rich'
|
|
117
|
+
/** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
|
|
118
|
+
readonly composerVariablesFor?: (
|
|
119
|
+
conversation: ConversationSummary,
|
|
120
|
+
context: Record<string, unknown> | undefined,
|
|
121
|
+
) => readonly RichComposerVariable[]
|
|
122
|
+
/** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
|
|
123
|
+
readonly onSendAttachments?: (
|
|
124
|
+
conversation: ConversationSummary,
|
|
125
|
+
files: readonly File[],
|
|
126
|
+
caption: string,
|
|
127
|
+
) => Promise<void>
|
|
128
|
+
/** Nota de voz. Ausente, o microfone não aparece. */
|
|
129
|
+
readonly onRecordAudio?: (conversation: ConversationSummary, file: File) => Promise<void>
|
|
130
|
+
readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
|
|
131
|
+
readonly onAttach?: (conversation: ConversationSummary, file: File) => Promise<void>
|
|
132
|
+
readonly extraUtilitiesFor?: (conversation: ConversationSummary) => readonly ConversationHeaderUtility[]
|
|
133
|
+
readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
134
|
+
readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
135
|
+
readonly renderRow?: (conversation: ConversationSummary) => ReactNode
|
|
136
|
+
readonly renderAboveTranscript?: (
|
|
137
|
+
conversation: ConversationSummary,
|
|
138
|
+
context: Record<string, unknown> | undefined,
|
|
139
|
+
) => ReactNode
|
|
140
|
+
readonly renderHeaderActions?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
141
|
+
readonly onSendTemplateToSelected?: (inbox: UseConversationsInboxResult) => void
|
|
142
|
+
/** Destino do link de reentrar no painel, mostrado junto do aviso de sessão expirada. */
|
|
143
|
+
readonly signInHref?: string
|
|
144
|
+
readonly className?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function ConversationsWorkspace({
|
|
148
|
+
labels: labelsOverride,
|
|
149
|
+
filters,
|
|
150
|
+
perPage,
|
|
151
|
+
markReadOnOpen,
|
|
152
|
+
serverPaginated,
|
|
153
|
+
initialConversationId,
|
|
154
|
+
initialWhatsappNumber,
|
|
155
|
+
simulator,
|
|
156
|
+
quickReplies,
|
|
157
|
+
quickReplyVariablesFor,
|
|
158
|
+
flowLabelOf,
|
|
159
|
+
onDownload,
|
|
160
|
+
requireTakeoverToReply,
|
|
161
|
+
messageSelection,
|
|
162
|
+
initialComposerText,
|
|
163
|
+
composer,
|
|
164
|
+
composerVariablesFor,
|
|
165
|
+
onSendAttachments,
|
|
166
|
+
onRecordAudio,
|
|
167
|
+
contextEntriesOf,
|
|
168
|
+
onAttach,
|
|
169
|
+
extraUtilitiesFor,
|
|
170
|
+
renderFilters,
|
|
171
|
+
renderBulkActions,
|
|
172
|
+
renderRow,
|
|
173
|
+
renderAboveTranscript,
|
|
174
|
+
renderHeaderActions,
|
|
175
|
+
onSendTemplateToSelected,
|
|
176
|
+
signInHref,
|
|
177
|
+
className,
|
|
178
|
+
}: ConversationsWorkspaceProps) {
|
|
179
|
+
const labels = { ...DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, ...labelsOverride }
|
|
180
|
+
const inbox = useConversationsInbox({
|
|
181
|
+
...(filters ? { filters } : {}),
|
|
182
|
+
...(perPage ? { perPage } : {}),
|
|
183
|
+
...(markReadOnOpen === undefined ? {} : { markReadOnOpen }),
|
|
184
|
+
...(serverPaginated ? { serverPaginated } : {}),
|
|
185
|
+
})
|
|
186
|
+
const [simulatorOpen, setSimulatorOpen] = useState(false)
|
|
187
|
+
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
|
188
|
+
const [openedFromLink, setOpenedFromLink] = useState<string | undefined>(undefined)
|
|
189
|
+
|
|
190
|
+
// Só seleciona depois que a conversa aparece na lista: o hook limpa qualquer seleção que não
|
|
191
|
+
// esteja em `conversations`, e no primeiro render a lista ainda está vazia.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const link = initialConversationId ?? initialWhatsappNumber
|
|
194
|
+
if (!link || openedFromLink === link) return
|
|
195
|
+
const target = inbox.conversations.find(
|
|
196
|
+
(conversation) =>
|
|
197
|
+
conversation.id === initialConversationId || conversation.whatsappNumber === initialWhatsappNumber,
|
|
198
|
+
)
|
|
199
|
+
if (!target) return
|
|
200
|
+
inbox.selectConversation(target.id)
|
|
201
|
+
setOpenedFromLink(link)
|
|
202
|
+
}, [initialConversationId, initialWhatsappNumber, openedFromLink, inbox])
|
|
203
|
+
|
|
204
|
+
const selected = inbox.selectedConversation
|
|
205
|
+
const conversations = useConversations()
|
|
206
|
+
const selectedId = selected?.id
|
|
207
|
+
const channel = selected?.channel ?? DEFAULT_CONVERSATION_CHANNEL
|
|
208
|
+
const handle = selected ? (selected.contactId ?? selected.whatsappNumber) : ''
|
|
209
|
+
const transport = simulator?.transports?.[channel]
|
|
210
|
+
const simulatorEnabled = Boolean(
|
|
211
|
+
simulator && (simulator.enabled ?? true) && (simulator.render ?? transport),
|
|
212
|
+
)
|
|
213
|
+
const showSimulator = simulatorEnabled && simulatorOpen && Boolean(selected)
|
|
214
|
+
|
|
215
|
+
// O transporte é recriado só quando a conversa (ou o canal dela) muda: identidade nova a cada
|
|
216
|
+
// render reiniciaria a leitura do transcript dentro do painel a cada digitação.
|
|
217
|
+
const simulatorClient = useMemo(
|
|
218
|
+
() => (transport && selectedId ? transport({ conversationId: selectedId, channel, handle }) : undefined),
|
|
219
|
+
[transport, selectedId, channel, handle],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const paneUtilities = useMemo(() => {
|
|
223
|
+
if (!selected) return undefined
|
|
224
|
+
const fromProduct = extraUtilitiesFor?.(selected) ?? []
|
|
225
|
+
if (!simulatorEnabled) return fromProduct
|
|
226
|
+
// No cabeçalho da conversa, ao lado de "Assumir atendimento": o simulador age sobre ESTA
|
|
227
|
+
// conversa; no cabeçalho da página ele parecia um filtro da inbox.
|
|
228
|
+
return [
|
|
229
|
+
...fromProduct,
|
|
230
|
+
{
|
|
231
|
+
key: 'simulator',
|
|
232
|
+
icon: simulator?.icon ?? <FlaskConical size={ICON_SIZE_ACTION} />,
|
|
233
|
+
label: simulator?.label ?? 'Simular cliente',
|
|
234
|
+
active: simulatorOpen,
|
|
235
|
+
run: () => setSimulatorOpen((open) => !open),
|
|
236
|
+
},
|
|
237
|
+
]
|
|
238
|
+
}, [selected, extraUtilitiesFor, simulatorEnabled, simulator, simulatorOpen])
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div className={`cv-workspace${className ? ` ${className}` : ''}`}>
|
|
242
|
+
<TooltipLayer />
|
|
243
|
+
{/* Cabeçalho denso em tela estreita: ícone + número. O rótulo escrito ocupava três linhas em
|
|
244
|
+
375px e empurrava a lista para fora da tela. */}
|
|
245
|
+
<header className="cv-workspace-header">
|
|
246
|
+
<div className="cv-workspace-header__titles">
|
|
247
|
+
<h1>{labels.title}</h1>
|
|
248
|
+
<p>
|
|
249
|
+
<span data-cv-tooltip={labels.conversations} className="cv-stat">
|
|
250
|
+
<MessagesSquare size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.totalCount}
|
|
251
|
+
<span className="cv-only-wide"> {labels.conversations}</span>
|
|
252
|
+
</span>
|
|
253
|
+
<span data-cv-tooltip={labels.waiting} className="cv-stat cv-workspace-header__waiting">
|
|
254
|
+
<Hourglass size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.waitingCount}
|
|
255
|
+
<span className="cv-only-wide"> {labels.waiting}</span>
|
|
256
|
+
</span>
|
|
257
|
+
<span data-cv-tooltip={labels.unread} className="cv-stat">
|
|
258
|
+
<Mail size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.unreadCount}
|
|
259
|
+
<span className="cv-only-wide"> {labels.unread}</span>
|
|
260
|
+
</span>
|
|
261
|
+
</p>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div className="cv-workspace-header__actions">
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={() => inbox.setWaitingOnly(!inbox.waitingOnly)}
|
|
268
|
+
aria-pressed={inbox.waitingOnly}
|
|
269
|
+
data-cv-tooltip={labels.waitingOnly} aria-label={labels.waitingOnly}
|
|
270
|
+
className={inbox.waitingOnly ? 'cv-workspace-toggle cv-workspace-toggle--on' : 'cv-workspace-toggle'}
|
|
271
|
+
>
|
|
272
|
+
<Hourglass size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
273
|
+
<span className="cv-only-wide">{labels.waitingOnly}</span>
|
|
274
|
+
</button>
|
|
275
|
+
{/* O contador só aparece com seleção: " (0)" era texto morto ao lado do ícone. */}
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
onClick={() => void inbox.markSelectedAsRead()}
|
|
279
|
+
disabled={inbox.selectedIds.size === 0 || inbox.busy}
|
|
280
|
+
data-cv-tooltip={labels.markSelectedAsRead}
|
|
281
|
+
aria-label={labels.markSelectedAsRead}
|
|
282
|
+
className="cv-workspace-toggle"
|
|
283
|
+
>
|
|
284
|
+
<Check size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
285
|
+
<span className="cv-only-wide">{labels.markSelectedAsRead}</span>
|
|
286
|
+
{inbox.selectedIds.size > 0 ? <span>({inbox.selectedIds.size})</span> : null}
|
|
287
|
+
</button>
|
|
288
|
+
{/* Só com não lida na tela e só onde o host implementa a rota: sem isso o botão zerava
|
|
289
|
+
nada e ainda assim ocupava o cabeçalho. */}
|
|
290
|
+
{inbox.canMarkAllRead && inbox.unreadCount > 0 ? (
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
onClick={() => void inbox.markAllAsRead()}
|
|
294
|
+
disabled={inbox.busy}
|
|
295
|
+
data-cv-tooltip={labels.markAllAsRead} aria-label={labels.markAllAsRead}
|
|
296
|
+
className="cv-workspace-toggle"
|
|
297
|
+
>
|
|
298
|
+
<CheckCheck size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
299
|
+
<span className="cv-only-wide">{labels.markAllAsRead}</span>
|
|
300
|
+
</button>
|
|
301
|
+
) : null}
|
|
302
|
+
{renderHeaderActions?.(inbox)}
|
|
303
|
+
</div>
|
|
304
|
+
</header>
|
|
305
|
+
|
|
306
|
+
{/* Silêncio aqui foi o que fez "não existe nenhuma conversa" parecer perda de dados: sem
|
|
307
|
+
sessão a API responde 401, a lista vem vazia e a tela não dizia nada. */}
|
|
308
|
+
{inbox.loadFailure ? (
|
|
309
|
+
<p role="alert" className="cv-workspace-failure">
|
|
310
|
+
{inbox.loadFailure}
|
|
311
|
+
{signInHref ? (
|
|
312
|
+
<>
|
|
313
|
+
{' '}
|
|
314
|
+
<a href={signInHref} className="cv-workspace-failure__link">
|
|
315
|
+
{labels.signIn}
|
|
316
|
+
</a>
|
|
317
|
+
</>
|
|
318
|
+
) : null}
|
|
319
|
+
</p>
|
|
320
|
+
) : null}
|
|
321
|
+
|
|
322
|
+
{/* Master/detail: em tela estreita a grade empilhava lista e conversa na mesma altura fixa e
|
|
323
|
+
cada uma virava uma fatia inútil — abaixo de `lg` mostra uma ou outra. As três colunas só
|
|
324
|
+
convivem a partir de `xl`; entre `lg` e `xl` a lista sai, porque enquanto se testa UMA
|
|
325
|
+
conversa é ela que menos importa. Cada coluna é um cartão com respiro entre elas:
|
|
326
|
+
separação por espaço lê mais rápido que por borda. */}
|
|
327
|
+
<div
|
|
328
|
+
className={`cv-workspace-grid${showSimulator ? ' cv-workspace-grid--with-simulator' : ''}`}
|
|
329
|
+
data-detail={selected ? 'open' : 'closed'}
|
|
330
|
+
>
|
|
331
|
+
<ConversationsInboxList
|
|
332
|
+
inbox={inbox}
|
|
333
|
+
labels={labels}
|
|
334
|
+
className="cv-workspace-list"
|
|
335
|
+
{...(renderFilters ? { renderFilters } : {})}
|
|
336
|
+
{...(renderBulkActions ? { renderBulkActions } : {})}
|
|
337
|
+
{...(renderRow ? { renderRow } : {})}
|
|
338
|
+
{...(onSendTemplateToSelected
|
|
339
|
+
? { onSendTemplateToSelected: () => onSendTemplateToSelected(inbox) }
|
|
340
|
+
: inbox.canListTemplates
|
|
341
|
+
? { onSendTemplateToSelected: () => setTemplateModalOpen(true) }
|
|
342
|
+
: {})}
|
|
343
|
+
/>
|
|
344
|
+
|
|
345
|
+
{/* `section`, não `main`: o layout do host já provê o `main` da página. */}
|
|
346
|
+
<section className="cv-workspace-detail">
|
|
347
|
+
{selected ? (
|
|
348
|
+
<ConversationPane
|
|
349
|
+
conversation={selected}
|
|
350
|
+
now={inbox.now}
|
|
351
|
+
busy={inbox.busy}
|
|
352
|
+
labels={labels}
|
|
353
|
+
{...(inbox.canTakeover ? { onTakeover: () => inbox.takeover(selected.id) } : {})}
|
|
354
|
+
{...(inbox.canTakeover ? { onReturnToBot: () => void inbox.releaseToBot(selected.id) } : {})}
|
|
355
|
+
{...(inbox.canFinalize ? { onFinish: () => void inbox.finalize(selected.id) } : {})}
|
|
356
|
+
{...(flowLabelOf ? { flowLabel: flowLabelOf(selected) } : {})}
|
|
357
|
+
{...(onDownload ? { onDownload: () => onDownload(selected) } : {})}
|
|
358
|
+
{...(requireTakeoverToReply ? { requireTakeoverToReply } : {})}
|
|
359
|
+
{...(messageSelection ? { messageSelection } : {})}
|
|
360
|
+
{...(initialComposerText ? { initialComposerText } : {})}
|
|
361
|
+
{...(composer ? { composer } : {})}
|
|
362
|
+
{...(composerVariablesFor ? { composerVariablesFor } : {})}
|
|
363
|
+
{...(onSendAttachments
|
|
364
|
+
? {
|
|
365
|
+
onSendAttachments: (files: readonly File[], caption: string) =>
|
|
366
|
+
onSendAttachments(selected, files, caption),
|
|
367
|
+
}
|
|
368
|
+
: {})}
|
|
369
|
+
{...(onRecordAudio ? { onRecordAudio: (file: File) => onRecordAudio(selected, file) } : {})}
|
|
370
|
+
onBack={inbox.clearSelection}
|
|
371
|
+
{...(paneUtilities ? { extraUtilities: paneUtilities } : {})}
|
|
372
|
+
{...(contextEntriesOf ? { contextEntriesOf } : {})}
|
|
373
|
+
{...(quickReplies ? { quickReplies } : {})}
|
|
374
|
+
{...(quickReplyVariablesFor ? { quickReplyVariablesFor } : {})}
|
|
375
|
+
{...(renderAboveTranscript ? { renderAboveTranscript } : {})}
|
|
376
|
+
{...(onAttach ? { onAttach: (file: File) => onAttach(selected, file) } : {})}
|
|
377
|
+
/>
|
|
378
|
+
) : (
|
|
379
|
+
<p className="cv-workspace-empty">{labels.emptyDetail}</p>
|
|
380
|
+
)}
|
|
381
|
+
</section>
|
|
382
|
+
|
|
383
|
+
{showSimulator && selected ? (
|
|
384
|
+
// `min-height:0` junto do `min-width:0`: sem isso a linha do grid cresce com o conteúdo do
|
|
385
|
+
// painel, o scroll interno nunca ativa e quem rola passa a ser a página inteira.
|
|
386
|
+
<div className="cv-workspace-simulator">
|
|
387
|
+
{simulator?.render ? (
|
|
388
|
+
simulator.render({ conversationId: selected.id, channel, close: () => setSimulatorOpen(false) })
|
|
389
|
+
) : simulatorClient && conversations ? (
|
|
390
|
+
// `key` pela conversa: trocar de contato sem remontar deixaria o transcript e o campo
|
|
391
|
+
// de texto do contato anterior na tela.
|
|
392
|
+
<ConversationSimulatorPanel
|
|
393
|
+
key={selected.id}
|
|
394
|
+
client={simulatorClient}
|
|
395
|
+
sse={conversations.sse}
|
|
396
|
+
conversationId={selected.id}
|
|
397
|
+
channel={channel}
|
|
398
|
+
displayHandle={formatContactHandle({ handle, channel })}
|
|
399
|
+
loadMessages={(conversationId) => conversations.api.fetchMessages(conversationId)}
|
|
400
|
+
onClose={() => setSimulatorOpen(false)}
|
|
401
|
+
{...(simulator?.labels ? { labels: simulator.labels } : {})}
|
|
402
|
+
{...(simulator?.uploadMedia ? { uploadMedia: simulator.uploadMedia } : {})}
|
|
403
|
+
{...(simulator?.pollIntervalMs ? { pollIntervalMs: simulator.pollIntervalMs } : {})}
|
|
404
|
+
/>
|
|
405
|
+
) : null}
|
|
406
|
+
</div>
|
|
407
|
+
) : null}
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
{templateModalOpen ? (
|
|
411
|
+
<BulkTemplateModal
|
|
412
|
+
labels={labels}
|
|
413
|
+
expiredCount={inbox.expiredSelectedCount}
|
|
414
|
+
sending={inbox.busy}
|
|
415
|
+
onClose={() => setTemplateModalOpen(false)}
|
|
416
|
+
onSend={(templateName) => {
|
|
417
|
+
void inbox.sendTemplateToSelected(templateName).then(() => setTemplateModalOpen(false))
|
|
418
|
+
}}
|
|
419
|
+
/>
|
|
420
|
+
) : null}
|
|
421
|
+
</div>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { ConversationsWorkspace } from './ConversationsWorkspace'
|
|
2
|
+
export type {
|
|
3
|
+
ConversationsWorkspaceProps,
|
|
4
|
+
ConversationsWorkspaceSimulator,
|
|
5
|
+
SimulatorTransportFactory,
|
|
6
|
+
SimulatorTransportParams,
|
|
7
|
+
} from './ConversationsWorkspace'
|
|
8
|
+
export { ConversationPane } from './ConversationPane'
|
|
9
|
+
export type { ConversationPaneProps } from './ConversationPane'
|
|
10
|
+
export { BulkTemplateModal } from './BulkTemplateModal'
|
|
11
|
+
export type { BulkTemplateModalProps } from './BulkTemplateModal'
|
|
12
|
+
export { ConversationsInboxList } from './ConversationsInboxList'
|
|
13
|
+
export type { ConversationsInboxListProps } from './ConversationsInboxList'
|
|
14
|
+
export { useConversationsInbox, CONVERSATIONS_PER_PAGE } from './useConversationsInbox'
|
|
15
|
+
export type { UseConversationsInboxParams, UseConversationsInboxResult } from './useConversationsInbox'
|
|
16
|
+
export { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './labels'
|
|
17
|
+
export type { ConversationsWorkspaceLabels } from './labels'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS as labels } from './labels'
|
|
4
|
+
|
|
5
|
+
describe('DEFAULT_CONVERSATIONS_WORKSPACE_LABELS', () => {
|
|
6
|
+
it('concorda o plural com a contagem', () => {
|
|
7
|
+
expect(labels.bulkSelected(1)).toBe('1 selecionada')
|
|
8
|
+
expect(labels.bulkSelected(3)).toBe('3 selecionadas')
|
|
9
|
+
expect(labels.bulkFinalizeConfirm(1)).toBe('Finalizar 1 conversa?')
|
|
10
|
+
expect(labels.bulkFinalizeConfirm(2)).toBe('Finalizar 2 conversas?')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('não mostra faixa quando não há resultado — "1–0 de 0" parecia defeito', () => {
|
|
14
|
+
expect(labels.rangeOf(1, 0, 0)).toBe('0 de 0')
|
|
15
|
+
expect(labels.rangeOf(1, 50, 137)).toBe('1–50 de 137')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Textos da inbox. Todos sobrescrevíveis: o produto troca vocabulário ("cliente" vira "lead") ou
|
|
3
|
+
* o idioma inteiro sem tocar no layout — e sem manter uma cópia da tela para isso.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ConversationsWorkspaceLabels {
|
|
7
|
+
readonly title: string
|
|
8
|
+
readonly conversations: string
|
|
9
|
+
readonly waiting: string
|
|
10
|
+
readonly unread: string
|
|
11
|
+
readonly waitingOnly: string
|
|
12
|
+
readonly markSelectedAsRead: string
|
|
13
|
+
readonly search: string
|
|
14
|
+
readonly windowLegend: string
|
|
15
|
+
readonly channelLegend: string
|
|
16
|
+
readonly selectAll: string
|
|
17
|
+
readonly emptyList: string
|
|
18
|
+
readonly emptyDetail: string
|
|
19
|
+
readonly bulkSelected: (count: number) => string
|
|
20
|
+
readonly bulkClear: string
|
|
21
|
+
readonly bulkFinalize: string
|
|
22
|
+
readonly bulkFinalizeConfirm: (count: number) => string
|
|
23
|
+
readonly bulkTemplate: string
|
|
24
|
+
readonly markAllAsRead: string
|
|
25
|
+
readonly templateModalTitle: string
|
|
26
|
+
readonly templateModalSearch: string
|
|
27
|
+
readonly templateModalEmpty: string
|
|
28
|
+
readonly templateModalCancel: string
|
|
29
|
+
readonly templateModalSending: string
|
|
30
|
+
readonly templateModalNoneExpired: string
|
|
31
|
+
readonly templateModalAvailable: (count: number) => string
|
|
32
|
+
readonly templateModalSend: (count: number) => string
|
|
33
|
+
readonly messagesSelected: (count: number) => string
|
|
34
|
+
readonly copySelected: string
|
|
35
|
+
readonly composerPlaceholder: string
|
|
36
|
+
readonly attachFailure: string
|
|
37
|
+
/** Tira um arquivo da fila antes de enviar. */
|
|
38
|
+
readonly attachmentRemove: string
|
|
39
|
+
readonly recordFailure: string
|
|
40
|
+
readonly sendFailure: string
|
|
41
|
+
readonly takeoverToReply: string
|
|
42
|
+
readonly signIn: string
|
|
43
|
+
readonly pageOf: (page: number, pageCount: number) => string
|
|
44
|
+
readonly rangeOf: (first: number, last: number, total: number) => string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabels = {
|
|
48
|
+
title: 'Conversas',
|
|
49
|
+
conversations: 'conversas',
|
|
50
|
+
waiting: 'aguardando',
|
|
51
|
+
unread: 'não lidas',
|
|
52
|
+
waitingOnly: 'Aguardando atendimento',
|
|
53
|
+
markSelectedAsRead: 'Marcar selecionadas como lidas',
|
|
54
|
+
search: 'Buscar conversa...',
|
|
55
|
+
windowLegend: 'Janela:',
|
|
56
|
+
channelLegend: 'Canal:',
|
|
57
|
+
selectAll: 'Selecionar todas',
|
|
58
|
+
emptyList: 'Nenhuma conversa encontrada.',
|
|
59
|
+
emptyDetail: 'Selecione uma conversa.',
|
|
60
|
+
bulkSelected: (count) => `${count} selecionada${count === 1 ? '' : 's'}`,
|
|
61
|
+
bulkClear: 'Limpar seleção',
|
|
62
|
+
bulkFinalize: 'Finalizar',
|
|
63
|
+
bulkFinalizeConfirm: (count) => `Finalizar ${count} conversa${count === 1 ? '' : 's'}?`,
|
|
64
|
+
bulkTemplate: 'Enviar template',
|
|
65
|
+
markAllAsRead: 'Marcar todas como lidas',
|
|
66
|
+
templateModalTitle: 'Escolher template',
|
|
67
|
+
templateModalSearch: 'Buscar template...',
|
|
68
|
+
templateModalEmpty: 'Nenhum template encontrado.',
|
|
69
|
+
templateModalCancel: 'Cancelar',
|
|
70
|
+
templateModalSending: 'Enviando…',
|
|
71
|
+
templateModalNoneExpired: 'Nenhuma das conversas selecionadas está fora da janela de 24h.',
|
|
72
|
+
templateModalAvailable: (count) => `${count} template${count === 1 ? '' : 's'} disponíve${count === 1 ? 'l' : 'is'}`,
|
|
73
|
+
templateModalSend: (count) => `Enviar para ${count}`,
|
|
74
|
+
messagesSelected: (count) => `${count} mensagem${count === 1 ? '' : 's'} selecionada${count === 1 ? '' : 's'}`,
|
|
75
|
+
copySelected: 'Copiar',
|
|
76
|
+
composerPlaceholder: 'Responder como atendente…',
|
|
77
|
+
attachFailure: 'Falha ao enviar o arquivo.',
|
|
78
|
+
attachmentRemove: 'Remover anexo',
|
|
79
|
+
recordFailure: 'Falha ao gravar o áudio.',
|
|
80
|
+
sendFailure: 'Falha ao enviar a mensagem.',
|
|
81
|
+
takeoverToReply: 'Assuma o atendimento para responder diretamente ao cliente.',
|
|
82
|
+
signIn: 'Entrar no painel',
|
|
83
|
+
pageOf: (page, pageCount) => `${page} / ${pageCount}`,
|
|
84
|
+
rangeOf: (first, last, total) => (total === 0 ? `0 de 0` : `${first}–${last} de ${total}`),
|
|
85
|
+
}
|