@adatechnology/conversations-ui 0.1.0-rc.3 → 0.1.0-rc.30
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/chunk-DXPSPUWF.js +110 -0
- package/dist/chunk-GY472G6E.js +2316 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +311 -4
- package/dist/flows/index.js +1322 -55
- package/dist/index.d.ts +1074 -42
- package/dist/index.js +3571 -742
- package/dist/preview/index.d.ts +328 -8
- package/dist/preview/index.js +902 -115
- package/dist/styles.css +819 -0
- package/dist/types-De5aN-E_.d.ts +502 -0
- package/package.json +3 -3
- 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 +42 -0
- package/src/ConversationRow.tsx +31 -7
- 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 +98 -22
- 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 +164 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- 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 +543 -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/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +35 -8
- package/src/flows/FlowNodePanel.tsx +149 -38
- package/src/flows/FlowPalette.tsx +13 -3
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1003 -0
- package/src/flows/flowCanvasModel.test.ts +293 -0
- package/src/flows/flowCanvasModel.ts +342 -0
- package/src/flows/flowEditorOps.test.ts +241 -0
- package/src/flows/flowEditorOps.ts +177 -0
- package/src/flows/flowGraph.ts +6 -6
- package/src/flows/index.ts +40 -1
- package/src/flows/labels.ts +141 -0
- package/src/flows/workspaceContract.test.ts +95 -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 +114 -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 +199 -13
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +89 -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 +33 -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 +468 -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.tsx +9 -1
- package/src/styles.css +783 -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 +346 -0
- package/src/workspace/index.ts +12 -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-N7B24WYD.js +0 -719
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
|
|
14
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
15
|
+
import { Check, CheckCheck, FlaskConical, Hourglass, Mail, MessagesSquare } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from '../icon.constant'
|
|
18
|
+
import type { ConversationContextEntry } from '../ConversationContextPanel'
|
|
19
|
+
import type { ConversationHeaderUtility } from '../ConversationHeader'
|
|
20
|
+
import type { QuickReply } from '../MessageComposer'
|
|
21
|
+
import type { RichComposerVariable } from '../RichMessageComposer'
|
|
22
|
+
import type { ConversationSummary } from '../providers/types'
|
|
23
|
+
import { BulkTemplateModal } from './BulkTemplateModal'
|
|
24
|
+
import { ConversationPane } from './ConversationPane'
|
|
25
|
+
import { ConversationsInboxList } from './ConversationsInboxList'
|
|
26
|
+
import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabels } from './labels'
|
|
27
|
+
import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
|
|
28
|
+
import { TooltipLayer } from '../Tooltip'
|
|
29
|
+
|
|
30
|
+
export interface ConversationsWorkspaceSimulator {
|
|
31
|
+
/**
|
|
32
|
+
* Desenha o painel do cliente. Fica com o host porque o simulador precisa da rota assinada no
|
|
33
|
+
* servidor DELE — e é o que mantém o `preview/` fora do bundle de quem não usa.
|
|
34
|
+
*/
|
|
35
|
+
render(params: { conversationId: string; close: () => void }): ReactNode
|
|
36
|
+
/** Ausente = ligado. Serve para esconder fora de desenvolvimento sem condicionar o JSX. */
|
|
37
|
+
readonly enabled?: boolean
|
|
38
|
+
/** Ícone da biblioteca (lucide) no utilitário do cabeçalho. Ausente, entra o frasco de teste. */
|
|
39
|
+
readonly icon?: ReactNode
|
|
40
|
+
readonly label?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ConversationsWorkspaceProps {
|
|
44
|
+
readonly labels?: Partial<ConversationsWorkspaceLabels>
|
|
45
|
+
readonly filters?: Record<string, string | undefined>
|
|
46
|
+
readonly perPage?: number
|
|
47
|
+
readonly markReadOnOpen?: boolean
|
|
48
|
+
/** Pede a página ao servidor em vez de fatiar no cliente. */
|
|
49
|
+
readonly serverPaginated?: boolean
|
|
50
|
+
/** Conversa a abrir na montagem (deep link `?id=`). */
|
|
51
|
+
readonly initialConversationId?: string | undefined
|
|
52
|
+
/** Idem, pelo telefone — é o que costuma vir no link de um alerta ou de um pedido. */
|
|
53
|
+
readonly initialWhatsappNumber?: string | undefined
|
|
54
|
+
readonly simulator?: ConversationsWorkspaceSimulator
|
|
55
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
56
|
+
readonly quickReplyVariablesFor?: (
|
|
57
|
+
conversation: ConversationSummary,
|
|
58
|
+
context: Record<string, unknown> | undefined,
|
|
59
|
+
) => Record<string, string>
|
|
60
|
+
/** Etapa do fluxo mostrada no painel de contexto. */
|
|
61
|
+
readonly flowLabelOf?: (conversation: ConversationSummary) => string | undefined
|
|
62
|
+
/** Substitui o download local do transcript (ex.: exportação completa pela rota do servidor). */
|
|
63
|
+
readonly onDownload?: (conversation: ConversationSummary) => void
|
|
64
|
+
/** Bloqueia o composer enquanto a conversa estiver com o bot. */
|
|
65
|
+
readonly requireTakeoverToReply?: boolean
|
|
66
|
+
/** Deixa marcar mensagens no transcript e copiar o trecho. */
|
|
67
|
+
readonly messageSelection?: boolean
|
|
68
|
+
/** Texto já no campo ao abrir a conversa (deep link que sugere a resposta). */
|
|
69
|
+
readonly initialComposerText?: string | undefined
|
|
70
|
+
/** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
|
|
71
|
+
readonly composer?: 'simple' | 'rich'
|
|
72
|
+
/** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
|
|
73
|
+
readonly composerVariablesFor?: (
|
|
74
|
+
conversation: ConversationSummary,
|
|
75
|
+
context: Record<string, unknown> | undefined,
|
|
76
|
+
) => readonly RichComposerVariable[]
|
|
77
|
+
/** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
|
|
78
|
+
readonly onSendAttachments?: (
|
|
79
|
+
conversation: ConversationSummary,
|
|
80
|
+
files: readonly File[],
|
|
81
|
+
caption: string,
|
|
82
|
+
) => Promise<void>
|
|
83
|
+
/** Nota de voz. Ausente, o microfone não aparece. */
|
|
84
|
+
readonly onRecordAudio?: (conversation: ConversationSummary, file: File) => Promise<void>
|
|
85
|
+
readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
|
|
86
|
+
readonly onAttach?: (conversation: ConversationSummary, file: File) => Promise<void>
|
|
87
|
+
readonly extraUtilitiesFor?: (conversation: ConversationSummary) => readonly ConversationHeaderUtility[]
|
|
88
|
+
readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
89
|
+
readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
90
|
+
readonly renderRow?: (conversation: ConversationSummary) => ReactNode
|
|
91
|
+
readonly renderAboveTranscript?: (
|
|
92
|
+
conversation: ConversationSummary,
|
|
93
|
+
context: Record<string, unknown> | undefined,
|
|
94
|
+
) => ReactNode
|
|
95
|
+
readonly renderHeaderActions?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
96
|
+
readonly onSendTemplateToSelected?: (inbox: UseConversationsInboxResult) => void
|
|
97
|
+
/** Destino do link de reentrar no painel, mostrado junto do aviso de sessão expirada. */
|
|
98
|
+
readonly signInHref?: string
|
|
99
|
+
readonly className?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function ConversationsWorkspace({
|
|
103
|
+
labels: labelsOverride,
|
|
104
|
+
filters,
|
|
105
|
+
perPage,
|
|
106
|
+
markReadOnOpen,
|
|
107
|
+
serverPaginated,
|
|
108
|
+
initialConversationId,
|
|
109
|
+
initialWhatsappNumber,
|
|
110
|
+
simulator,
|
|
111
|
+
quickReplies,
|
|
112
|
+
quickReplyVariablesFor,
|
|
113
|
+
flowLabelOf,
|
|
114
|
+
onDownload,
|
|
115
|
+
requireTakeoverToReply,
|
|
116
|
+
messageSelection,
|
|
117
|
+
initialComposerText,
|
|
118
|
+
composer,
|
|
119
|
+
composerVariablesFor,
|
|
120
|
+
onSendAttachments,
|
|
121
|
+
onRecordAudio,
|
|
122
|
+
contextEntriesOf,
|
|
123
|
+
onAttach,
|
|
124
|
+
extraUtilitiesFor,
|
|
125
|
+
renderFilters,
|
|
126
|
+
renderBulkActions,
|
|
127
|
+
renderRow,
|
|
128
|
+
renderAboveTranscript,
|
|
129
|
+
renderHeaderActions,
|
|
130
|
+
onSendTemplateToSelected,
|
|
131
|
+
signInHref,
|
|
132
|
+
className,
|
|
133
|
+
}: ConversationsWorkspaceProps) {
|
|
134
|
+
const labels = { ...DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, ...labelsOverride }
|
|
135
|
+
const inbox = useConversationsInbox({
|
|
136
|
+
...(filters ? { filters } : {}),
|
|
137
|
+
...(perPage ? { perPage } : {}),
|
|
138
|
+
...(markReadOnOpen === undefined ? {} : { markReadOnOpen }),
|
|
139
|
+
...(serverPaginated ? { serverPaginated } : {}),
|
|
140
|
+
})
|
|
141
|
+
const [simulatorOpen, setSimulatorOpen] = useState(false)
|
|
142
|
+
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
|
143
|
+
const [openedFromLink, setOpenedFromLink] = useState<string | undefined>(undefined)
|
|
144
|
+
|
|
145
|
+
// Só seleciona depois que a conversa aparece na lista: o hook limpa qualquer seleção que não
|
|
146
|
+
// esteja em `conversations`, e no primeiro render a lista ainda está vazia.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const link = initialConversationId ?? initialWhatsappNumber
|
|
149
|
+
if (!link || openedFromLink === link) return
|
|
150
|
+
const target = inbox.conversations.find(
|
|
151
|
+
(conversation) =>
|
|
152
|
+
conversation.id === initialConversationId || conversation.whatsappNumber === initialWhatsappNumber,
|
|
153
|
+
)
|
|
154
|
+
if (!target) return
|
|
155
|
+
inbox.selectConversation(target.id)
|
|
156
|
+
setOpenedFromLink(link)
|
|
157
|
+
}, [initialConversationId, initialWhatsappNumber, openedFromLink, inbox])
|
|
158
|
+
|
|
159
|
+
const selected = inbox.selectedConversation
|
|
160
|
+
const simulatorEnabled = Boolean(simulator && (simulator.enabled ?? true))
|
|
161
|
+
const showSimulator = simulatorEnabled && simulatorOpen && Boolean(selected)
|
|
162
|
+
|
|
163
|
+
const paneUtilities = useMemo(() => {
|
|
164
|
+
if (!selected) return undefined
|
|
165
|
+
const fromProduct = extraUtilitiesFor?.(selected) ?? []
|
|
166
|
+
if (!simulatorEnabled) return fromProduct
|
|
167
|
+
// No cabeçalho da conversa, ao lado de "Assumir atendimento": o simulador age sobre ESTA
|
|
168
|
+
// conversa; no cabeçalho da página ele parecia um filtro da inbox.
|
|
169
|
+
return [
|
|
170
|
+
...fromProduct,
|
|
171
|
+
{
|
|
172
|
+
key: 'simulator',
|
|
173
|
+
icon: simulator?.icon ?? <FlaskConical size={ICON_SIZE_ACTION} />,
|
|
174
|
+
label: simulator?.label ?? 'Simular cliente',
|
|
175
|
+
active: simulatorOpen,
|
|
176
|
+
run: () => setSimulatorOpen((open) => !open),
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
}, [selected, extraUtilitiesFor, simulatorEnabled, simulator, simulatorOpen])
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className={`cv-workspace${className ? ` ${className}` : ''}`}>
|
|
183
|
+
<TooltipLayer />
|
|
184
|
+
{/* Cabeçalho denso em tela estreita: ícone + número. O rótulo escrito ocupava três linhas em
|
|
185
|
+
375px e empurrava a lista para fora da tela. */}
|
|
186
|
+
<header className="cv-workspace-header">
|
|
187
|
+
<div className="cv-workspace-header__titles">
|
|
188
|
+
<h1>{labels.title}</h1>
|
|
189
|
+
<p>
|
|
190
|
+
<span data-cv-tooltip={labels.conversations} className="cv-stat">
|
|
191
|
+
<MessagesSquare size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.totalCount}
|
|
192
|
+
<span className="cv-only-wide"> {labels.conversations}</span>
|
|
193
|
+
</span>
|
|
194
|
+
<span data-cv-tooltip={labels.waiting} className="cv-stat cv-workspace-header__waiting">
|
|
195
|
+
<Hourglass size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.waitingCount}
|
|
196
|
+
<span className="cv-only-wide"> {labels.waiting}</span>
|
|
197
|
+
</span>
|
|
198
|
+
<span data-cv-tooltip={labels.unread} className="cv-stat">
|
|
199
|
+
<Mail size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.unreadCount}
|
|
200
|
+
<span className="cv-only-wide"> {labels.unread}</span>
|
|
201
|
+
</span>
|
|
202
|
+
</p>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="cv-workspace-header__actions">
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => inbox.setWaitingOnly(!inbox.waitingOnly)}
|
|
209
|
+
aria-pressed={inbox.waitingOnly}
|
|
210
|
+
data-cv-tooltip={labels.waitingOnly} aria-label={labels.waitingOnly}
|
|
211
|
+
className={inbox.waitingOnly ? 'cv-workspace-toggle cv-workspace-toggle--on' : 'cv-workspace-toggle'}
|
|
212
|
+
>
|
|
213
|
+
<Hourglass size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
214
|
+
<span className="cv-only-wide">{labels.waitingOnly}</span>
|
|
215
|
+
</button>
|
|
216
|
+
{/* O contador só aparece com seleção: " (0)" era texto morto ao lado do ícone. */}
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={() => void inbox.markSelectedAsRead()}
|
|
220
|
+
disabled={inbox.selectedIds.size === 0 || inbox.busy}
|
|
221
|
+
data-cv-tooltip={labels.markSelectedAsRead}
|
|
222
|
+
aria-label={labels.markSelectedAsRead}
|
|
223
|
+
className="cv-workspace-toggle"
|
|
224
|
+
>
|
|
225
|
+
<Check size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
226
|
+
<span className="cv-only-wide">{labels.markSelectedAsRead}</span>
|
|
227
|
+
{inbox.selectedIds.size > 0 ? <span>({inbox.selectedIds.size})</span> : null}
|
|
228
|
+
</button>
|
|
229
|
+
{/* Só com não lida na tela e só onde o host implementa a rota: sem isso o botão zerava
|
|
230
|
+
nada e ainda assim ocupava o cabeçalho. */}
|
|
231
|
+
{inbox.canMarkAllRead && inbox.unreadCount > 0 ? (
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => void inbox.markAllAsRead()}
|
|
235
|
+
disabled={inbox.busy}
|
|
236
|
+
data-cv-tooltip={labels.markAllAsRead} aria-label={labels.markAllAsRead}
|
|
237
|
+
className="cv-workspace-toggle"
|
|
238
|
+
>
|
|
239
|
+
<CheckCheck size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
240
|
+
<span className="cv-only-wide">{labels.markAllAsRead}</span>
|
|
241
|
+
</button>
|
|
242
|
+
) : null}
|
|
243
|
+
{renderHeaderActions?.(inbox)}
|
|
244
|
+
</div>
|
|
245
|
+
</header>
|
|
246
|
+
|
|
247
|
+
{/* Silêncio aqui foi o que fez "não existe nenhuma conversa" parecer perda de dados: sem
|
|
248
|
+
sessão a API responde 401, a lista vem vazia e a tela não dizia nada. */}
|
|
249
|
+
{inbox.loadFailure ? (
|
|
250
|
+
<p role="alert" className="cv-workspace-failure">
|
|
251
|
+
{inbox.loadFailure}
|
|
252
|
+
{signInHref ? (
|
|
253
|
+
<>
|
|
254
|
+
{' '}
|
|
255
|
+
<a href={signInHref} className="cv-workspace-failure__link">
|
|
256
|
+
{labels.signIn}
|
|
257
|
+
</a>
|
|
258
|
+
</>
|
|
259
|
+
) : null}
|
|
260
|
+
</p>
|
|
261
|
+
) : null}
|
|
262
|
+
|
|
263
|
+
{/* Master/detail: em tela estreita a grade empilhava lista e conversa na mesma altura fixa e
|
|
264
|
+
cada uma virava uma fatia inútil — abaixo de `lg` mostra uma ou outra. As três colunas só
|
|
265
|
+
convivem a partir de `xl`; entre `lg` e `xl` a lista sai, porque enquanto se testa UMA
|
|
266
|
+
conversa é ela que menos importa. Cada coluna é um cartão com respiro entre elas:
|
|
267
|
+
separação por espaço lê mais rápido que por borda. */}
|
|
268
|
+
<div
|
|
269
|
+
className={`cv-workspace-grid${showSimulator ? ' cv-workspace-grid--with-simulator' : ''}`}
|
|
270
|
+
data-detail={selected ? 'open' : 'closed'}
|
|
271
|
+
>
|
|
272
|
+
<ConversationsInboxList
|
|
273
|
+
inbox={inbox}
|
|
274
|
+
labels={labels}
|
|
275
|
+
className="cv-workspace-list"
|
|
276
|
+
{...(renderFilters ? { renderFilters } : {})}
|
|
277
|
+
{...(renderBulkActions ? { renderBulkActions } : {})}
|
|
278
|
+
{...(renderRow ? { renderRow } : {})}
|
|
279
|
+
{...(onSendTemplateToSelected
|
|
280
|
+
? { onSendTemplateToSelected: () => onSendTemplateToSelected(inbox) }
|
|
281
|
+
: inbox.canListTemplates
|
|
282
|
+
? { onSendTemplateToSelected: () => setTemplateModalOpen(true) }
|
|
283
|
+
: {})}
|
|
284
|
+
/>
|
|
285
|
+
|
|
286
|
+
{/* `section`, não `main`: o layout do host já provê o `main` da página. */}
|
|
287
|
+
<section className="cv-workspace-detail">
|
|
288
|
+
{selected ? (
|
|
289
|
+
<ConversationPane
|
|
290
|
+
conversation={selected}
|
|
291
|
+
now={inbox.now}
|
|
292
|
+
busy={inbox.busy}
|
|
293
|
+
labels={labels}
|
|
294
|
+
{...(inbox.canTakeover ? { onTakeover: () => inbox.takeover(selected.id) } : {})}
|
|
295
|
+
{...(inbox.canTakeover ? { onReturnToBot: () => void inbox.releaseToBot(selected.id) } : {})}
|
|
296
|
+
{...(inbox.canFinalize ? { onFinish: () => void inbox.finalize(selected.id) } : {})}
|
|
297
|
+
{...(flowLabelOf ? { flowLabel: flowLabelOf(selected) } : {})}
|
|
298
|
+
{...(onDownload ? { onDownload: () => onDownload(selected) } : {})}
|
|
299
|
+
{...(requireTakeoverToReply ? { requireTakeoverToReply } : {})}
|
|
300
|
+
{...(messageSelection ? { messageSelection } : {})}
|
|
301
|
+
{...(initialComposerText ? { initialComposerText } : {})}
|
|
302
|
+
{...(composer ? { composer } : {})}
|
|
303
|
+
{...(composerVariablesFor ? { composerVariablesFor } : {})}
|
|
304
|
+
{...(onSendAttachments
|
|
305
|
+
? {
|
|
306
|
+
onSendAttachments: (files: readonly File[], caption: string) =>
|
|
307
|
+
onSendAttachments(selected, files, caption),
|
|
308
|
+
}
|
|
309
|
+
: {})}
|
|
310
|
+
{...(onRecordAudio ? { onRecordAudio: (file: File) => onRecordAudio(selected, file) } : {})}
|
|
311
|
+
onBack={inbox.clearSelection}
|
|
312
|
+
{...(paneUtilities ? { extraUtilities: paneUtilities } : {})}
|
|
313
|
+
{...(contextEntriesOf ? { contextEntriesOf } : {})}
|
|
314
|
+
{...(quickReplies ? { quickReplies } : {})}
|
|
315
|
+
{...(quickReplyVariablesFor ? { quickReplyVariablesFor } : {})}
|
|
316
|
+
{...(renderAboveTranscript ? { renderAboveTranscript } : {})}
|
|
317
|
+
{...(onAttach ? { onAttach: (file: File) => onAttach(selected, file) } : {})}
|
|
318
|
+
/>
|
|
319
|
+
) : (
|
|
320
|
+
<p className="cv-workspace-empty">{labels.emptyDetail}</p>
|
|
321
|
+
)}
|
|
322
|
+
</section>
|
|
323
|
+
|
|
324
|
+
{showSimulator && selected ? (
|
|
325
|
+
// `min-height:0` junto do `min-width:0`: sem isso a linha do grid cresce com o conteúdo do
|
|
326
|
+
// painel, o scroll interno nunca ativa e quem rola passa a ser a página inteira.
|
|
327
|
+
<div className="cv-workspace-simulator">
|
|
328
|
+
{simulator?.render({ conversationId: selected.id, close: () => setSimulatorOpen(false) })}
|
|
329
|
+
</div>
|
|
330
|
+
) : null}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{templateModalOpen ? (
|
|
334
|
+
<BulkTemplateModal
|
|
335
|
+
labels={labels}
|
|
336
|
+
expiredCount={inbox.expiredSelectedCount}
|
|
337
|
+
sending={inbox.busy}
|
|
338
|
+
onClose={() => setTemplateModalOpen(false)}
|
|
339
|
+
onSend={(templateName) => {
|
|
340
|
+
void inbox.sendTemplateToSelected(templateName).then(() => setTemplateModalOpen(false))
|
|
341
|
+
}}
|
|
342
|
+
/>
|
|
343
|
+
) : null}
|
|
344
|
+
</div>
|
|
345
|
+
)
|
|
346
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { ConversationsWorkspace } from './ConversationsWorkspace'
|
|
2
|
+
export type { ConversationsWorkspaceProps, ConversationsWorkspaceSimulator } from './ConversationsWorkspace'
|
|
3
|
+
export { ConversationPane } from './ConversationPane'
|
|
4
|
+
export type { ConversationPaneProps } from './ConversationPane'
|
|
5
|
+
export { BulkTemplateModal } from './BulkTemplateModal'
|
|
6
|
+
export type { BulkTemplateModalProps } from './BulkTemplateModal'
|
|
7
|
+
export { ConversationsInboxList } from './ConversationsInboxList'
|
|
8
|
+
export type { ConversationsInboxListProps } from './ConversationsInboxList'
|
|
9
|
+
export { useConversationsInbox, CONVERSATIONS_PER_PAGE } from './useConversationsInbox'
|
|
10
|
+
export type { UseConversationsInboxParams, UseConversationsInboxResult } from './useConversationsInbox'
|
|
11
|
+
export { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './labels'
|
|
12
|
+
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
|
+
}
|