@adatechnology/conversations-ui 0.1.0-rc.21 → 0.1.0-rc.23
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 +237 -3
- package/dist/index.js +854 -0
- package/dist/preview/index.d.ts +23 -1
- package/dist/preview/index.js +48 -14
- package/dist/styles.css +559 -0
- package/package.json +1 -1
- package/src/index.ts +15 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +89 -0
- package/src/preview/createPreviewWebhookClient.test.ts +11 -4
- package/src/preview/index.ts +3 -0
- package/src/styles.css +542 -0
- package/src/workspace/BulkTemplateModal.tsx +130 -0
- package/src/workspace/ConversationPane.tsx +287 -0
- package/src/workspace/ConversationsInboxList.tsx +185 -0
- package/src/workspace/ConversationsWorkspace.tsx +306 -0
- package/src/workspace/index.ts +12 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +80 -0
- package/src/workspace/useConversationsInbox.ts +329 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coluna do meio: cabeçalho, contexto, documentos, transcript e composer da conversa aberta.
|
|
3
|
+
*
|
|
4
|
+
* Era a peça mais copiada entre os produtos, e a que mais divergia: um perdia o salto para a
|
|
5
|
+
* última mensagem, outro engolia falha de anexo, outro não abria a biblioteca de arquivos.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
9
|
+
|
|
10
|
+
import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
|
|
11
|
+
import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
|
|
12
|
+
import { ConversationHeader, type ConversationHeaderUtility } from '../ConversationHeader'
|
|
13
|
+
import { ConversationWallpaper } from '../Wallpaper'
|
|
14
|
+
import { DateDivider } from '../DateDivider'
|
|
15
|
+
import { MessageBubble } from '../MessageBubble'
|
|
16
|
+
import { MessageComposer, type QuickReply } from '../MessageComposer'
|
|
17
|
+
import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
|
|
18
|
+
import { windowOf } from '../conversationWindow'
|
|
19
|
+
import {
|
|
20
|
+
buildTranscriptFilename,
|
|
21
|
+
buildTranscriptText,
|
|
22
|
+
downloadTextFile,
|
|
23
|
+
} from '../conversationTranscript'
|
|
24
|
+
import { useConversationContext } from '../hooks/useConversationContext'
|
|
25
|
+
import { useConversationMessages } from '../hooks/useConversationMessages'
|
|
26
|
+
import { useConversationRealtime } from '../hooks/useConversationRealtime'
|
|
27
|
+
import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
|
|
28
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
29
|
+
import type { ConversationSummary } from '../providers/types'
|
|
30
|
+
import type { ConversationsWorkspaceLabels } from './labels'
|
|
31
|
+
|
|
32
|
+
export interface ConversationPaneProps {
|
|
33
|
+
readonly conversation: ConversationSummary
|
|
34
|
+
readonly now: number
|
|
35
|
+
readonly busy: boolean
|
|
36
|
+
readonly labels: ConversationsWorkspaceLabels
|
|
37
|
+
readonly onTakeover?: (() => void) | undefined
|
|
38
|
+
readonly onReturnToBot?: (() => void) | undefined
|
|
39
|
+
readonly onFinish?: (() => void) | undefined
|
|
40
|
+
readonly onBack: () => void
|
|
41
|
+
readonly extraUtilities?: readonly ConversationHeaderUtility[]
|
|
42
|
+
/** Traduz o contexto cru do produto nas linhas do painel. Ausente, o painel não aparece. */
|
|
43
|
+
readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
|
|
44
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
45
|
+
/**
|
|
46
|
+
* Recebe o contexto junto porque o dado que interessa à variável (o nome que o bot perguntou,
|
|
47
|
+
* por exemplo) vive no contexto do fluxo, não no resumo da listagem.
|
|
48
|
+
*/
|
|
49
|
+
readonly quickReplyVariablesFor?: (
|
|
50
|
+
conversation: ConversationSummary,
|
|
51
|
+
context: Record<string, unknown> | undefined,
|
|
52
|
+
) => Record<string, string>
|
|
53
|
+
/** Etapa do fluxo ao lado do contexto (ex.: "Anotando o pedido"). */
|
|
54
|
+
readonly flowLabel?: string | undefined
|
|
55
|
+
/**
|
|
56
|
+
* Substitui o download local do transcript. Existe para o produto que exporta pela rota do
|
|
57
|
+
* servidor, onde o arquivo sai completo em vez de só com o que a tela carregou.
|
|
58
|
+
*/
|
|
59
|
+
readonly onDownload?: (() => void) | undefined
|
|
60
|
+
/**
|
|
61
|
+
* Bloqueia o composer enquanto a conversa estiver com o bot. Ligado, responder sem assumir
|
|
62
|
+
* atropelaria o fluxo automático no meio de uma pergunta.
|
|
63
|
+
*/
|
|
64
|
+
readonly requireTakeoverToReply?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* Deixa marcar mensagens e copiar o trecho. É o que se faz para levar um pedaço da conversa a um
|
|
67
|
+
* e-mail ou a um chamado, sem baixar o transcript inteiro.
|
|
68
|
+
*/
|
|
69
|
+
readonly messageSelection?: boolean
|
|
70
|
+
/** Texto já no campo ao abrir (deep link que sugere a resposta). */
|
|
71
|
+
readonly initialComposerText?: string | undefined
|
|
72
|
+
/** Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). */
|
|
73
|
+
readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode
|
|
74
|
+
readonly onAttach?: ((file: File) => Promise<void>) | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ConversationPane({
|
|
78
|
+
conversation,
|
|
79
|
+
now,
|
|
80
|
+
busy,
|
|
81
|
+
labels,
|
|
82
|
+
onTakeover,
|
|
83
|
+
onReturnToBot,
|
|
84
|
+
onFinish,
|
|
85
|
+
onBack,
|
|
86
|
+
extraUtilities,
|
|
87
|
+
contextEntriesOf,
|
|
88
|
+
quickReplies,
|
|
89
|
+
quickReplyVariablesFor,
|
|
90
|
+
flowLabel,
|
|
91
|
+
onDownload,
|
|
92
|
+
requireTakeoverToReply,
|
|
93
|
+
messageSelection,
|
|
94
|
+
initialComposerText,
|
|
95
|
+
renderAboveTranscript,
|
|
96
|
+
onAttach,
|
|
97
|
+
}: ConversationPaneProps) {
|
|
98
|
+
const context = useConversations()
|
|
99
|
+
if (!context) {
|
|
100
|
+
throw new Error('ConversationPane requires an ancestor <ConversationsProvider>')
|
|
101
|
+
}
|
|
102
|
+
const { api } = context
|
|
103
|
+
|
|
104
|
+
const { messages, refetch } = useConversationMessages(conversation.id)
|
|
105
|
+
const { context: conversationContext } = useConversationContext(conversation.id)
|
|
106
|
+
const [documentsOpen, setDocumentsOpen] = useState(false)
|
|
107
|
+
const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
|
|
108
|
+
const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
|
|
109
|
+
const [draft, setDraft] = useState(initialComposerText ?? '')
|
|
110
|
+
|
|
111
|
+
// Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
|
|
112
|
+
// levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
setSelectedMessageIds(new Set())
|
|
115
|
+
setDraft(initialComposerText ?? '')
|
|
116
|
+
}, [conversation.id, initialComposerText])
|
|
117
|
+
|
|
118
|
+
function toggleMessageSelected(messageId: string): void {
|
|
119
|
+
setSelectedMessageIds((current) => {
|
|
120
|
+
const next = new Set(current)
|
|
121
|
+
if (next.has(messageId)) next.delete(messageId)
|
|
122
|
+
else next.add(messageId)
|
|
123
|
+
return next
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Data, quem falou e o texto: fora do painel o trecho precisa se sustentar sozinho. */
|
|
128
|
+
function copySelectedMessages(): void {
|
|
129
|
+
const transcript = messages
|
|
130
|
+
.filter((message) => selectedMessageIds.has(message.id))
|
|
131
|
+
.map(
|
|
132
|
+
(message) =>
|
|
133
|
+
`[${new Date(message.timestamp).toLocaleString()}] ${message.sender}: ${message.content ?? `(${message.type})`}`,
|
|
134
|
+
)
|
|
135
|
+
.join('\n')
|
|
136
|
+
void navigator.clipboard.writeText(transcript)
|
|
137
|
+
setSelectedMessageIds(new Set())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Abrir no topo do histórico obrigava a rolar semanas até a última mensagem — que é sempre o que
|
|
141
|
+
// interessa. O hook salta ao trocar de conversa sem arrastar quem estiver lendo o histórico.
|
|
142
|
+
const scroll = useScrollToLatestMessage({ conversationId: conversation.id, messageCount: messages.length })
|
|
143
|
+
|
|
144
|
+
// O evento traz só `{ direction, sender }` — quem tem o conteúdo é a query.
|
|
145
|
+
useConversationRealtime(conversation.id, () => {
|
|
146
|
+
void refetch()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const blocked = isWindowBlocking(
|
|
150
|
+
windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel }),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Falha vira aviso na tela em vez de exceção silenciosa: o atendente escreveu ou gravou, achou
|
|
155
|
+
* que mandou, e sem retorno não teria como saber que o cliente não recebeu nada.
|
|
156
|
+
*/
|
|
157
|
+
async function runSend(action: () => Promise<unknown>, fallback: string): Promise<void> {
|
|
158
|
+
setSendFailure(undefined)
|
|
159
|
+
try {
|
|
160
|
+
await action()
|
|
161
|
+
await refetch()
|
|
162
|
+
} catch (error: unknown) {
|
|
163
|
+
setSendFailure(error instanceof Error ? error.message : fallback)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function handleSend(text: string): Promise<void> {
|
|
168
|
+
await runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleAttach(file: File): Promise<void> {
|
|
172
|
+
if (!onAttach) return
|
|
173
|
+
await runSend(() => onAttach(file), labels.attachFailure)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function handleDownload(): void {
|
|
177
|
+
downloadTextFile(
|
|
178
|
+
buildTranscriptFilename(conversation.whatsappNumber, new Date()),
|
|
179
|
+
buildTranscriptText({
|
|
180
|
+
messages,
|
|
181
|
+
whatsappNumber: conversation.whatsappNumber,
|
|
182
|
+
clientName: conversation.clientName,
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const contextEntries = contextEntriesOf?.(conversationContext)
|
|
188
|
+
const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="cv-workspace-pane">
|
|
192
|
+
<ConversationHeader
|
|
193
|
+
conversation={conversation}
|
|
194
|
+
busy={busy}
|
|
195
|
+
{...(onTakeover ? { onTakeover } : {})}
|
|
196
|
+
{...(onReturnToBot ? { onReturnToBot } : {})}
|
|
197
|
+
{...(onFinish ? { onFinish } : {})}
|
|
198
|
+
onDownload={onDownload ?? handleDownload}
|
|
199
|
+
onBack={onBack}
|
|
200
|
+
onOpenDocuments={() => setDocumentsOpen(!documentsOpen)}
|
|
201
|
+
documentsOpen={documentsOpen}
|
|
202
|
+
{...(extraUtilities ? { extraUtilities } : {})}
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
{(contextEntries && contextEntries.length > 0) || flowLabel ? (
|
|
206
|
+
<ConversationContextPanel entries={contextEntries ?? []} {...(flowLabel ? { flowLabel } : {})} />
|
|
207
|
+
) : null}
|
|
208
|
+
<ConversationDocumentsPanel conversationId={conversation.id} open={documentsOpen} />
|
|
209
|
+
|
|
210
|
+
{renderAboveTranscript?.(conversation)}
|
|
211
|
+
|
|
212
|
+
{/* Mesmo wallpaper do preview do cliente: atendente e cliente devem ver a conversa com a
|
|
213
|
+
mesma aparência, senão o preview deixa de ser referência confiável. */}
|
|
214
|
+
<ConversationWallpaper
|
|
215
|
+
ref={scroll.containerRef}
|
|
216
|
+
onScroll={scroll.handleScroll}
|
|
217
|
+
className="cv-workspace-transcript"
|
|
218
|
+
>
|
|
219
|
+
{messages.map((message, index) => {
|
|
220
|
+
const previous = index > 0 ? messages[index - 1] : undefined
|
|
221
|
+
const startsNewDay =
|
|
222
|
+
!previous || new Date(message.timestamp).toDateString() !== new Date(previous.timestamp).toDateString()
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div key={message.id}>
|
|
226
|
+
{startsNewDay ? <DateDivider iso={message.timestamp} /> : null}
|
|
227
|
+
<MessageBubble
|
|
228
|
+
message={message}
|
|
229
|
+
isMine={message.direction === 'outbound'}
|
|
230
|
+
isFirstInGroup={!previous || previous.sender !== message.sender}
|
|
231
|
+
{...(messageSelection
|
|
232
|
+
? {
|
|
233
|
+
isSelecting: selectedMessageIds.size > 0,
|
|
234
|
+
isSelected: selectedMessageIds.has(message.id),
|
|
235
|
+
onToggleSelect: () => toggleMessageSelected(message.id),
|
|
236
|
+
}
|
|
237
|
+
: {})}
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
})}
|
|
242
|
+
</ConversationWallpaper>
|
|
243
|
+
|
|
244
|
+
{messageSelection && selectedMessageIds.size > 0 ? (
|
|
245
|
+
<div className="cv-workspace-selection">
|
|
246
|
+
<span>{labels.messagesSelected(selectedMessageIds.size)}</span>
|
|
247
|
+
<div className="cv-workspace-selection__actions">
|
|
248
|
+
<button type="button" onClick={() => setSelectedMessageIds(new Set())}>
|
|
249
|
+
{labels.bulkClear}
|
|
250
|
+
</button>
|
|
251
|
+
<button type="button" onClick={copySelectedMessages}>
|
|
252
|
+
{labels.copySelected}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
) : null}
|
|
257
|
+
|
|
258
|
+
{sendFailure ? (
|
|
259
|
+
<p role="alert" className="cv-workspace-alert">
|
|
260
|
+
{sendFailure}
|
|
261
|
+
</p>
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
{blocked ? (
|
|
265
|
+
<WindowExpiredNotice
|
|
266
|
+
disabled={busy}
|
|
267
|
+
onSendTemplate={() => void api.sendTemplate(conversation.id, {}).then(() => refetch())}
|
|
268
|
+
/>
|
|
269
|
+
) : botOwnsConversation ? (
|
|
270
|
+
// Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
|
|
271
|
+
<p className="cv-workspace-notice">{labels.takeoverToReply}</p>
|
|
272
|
+
) : (
|
|
273
|
+
<MessageComposer
|
|
274
|
+
value={draft}
|
|
275
|
+
onChange={setDraft}
|
|
276
|
+
onSend={(text) => void handleSend(text)}
|
|
277
|
+
// Habilita clipe E microfone: o composer desenha o gravador sozinho quando existe um jeito
|
|
278
|
+
// de entregar arquivo, porque áudio gravado é um anexo como qualquer outro.
|
|
279
|
+
{...(onAttach ? { onAttach: (file: File) => void handleAttach(file) } : {})}
|
|
280
|
+
placeholder={labels.composerPlaceholder}
|
|
281
|
+
{...(quickReplies ? { quickReplies } : {})}
|
|
282
|
+
{...(quickReplyVariablesFor ? { quickReplyVariables: quickReplyVariablesFor(conversation, conversationContext) } : {})}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coluna da esquerda: busca, filtros de janela e canal, seleção em massa, linhas e paginação.
|
|
3
|
+
*
|
|
4
|
+
* Recebe o resultado do hook inteiro em vez de vinte props: é a mesma máquina de estado, e passar
|
|
5
|
+
* peça por peça só criaria oportunidade de um produto ligar metade dos controles.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ReactNode } from 'react'
|
|
9
|
+
|
|
10
|
+
import { ChannelIcon } from '../ChannelIcon'
|
|
11
|
+
import { ConversationRow } from '../ConversationRow'
|
|
12
|
+
import { CHANNEL_FILTER_ALL } from '../conversationChannel'
|
|
13
|
+
import { WINDOW_FILTERS } from '../conversationWindow'
|
|
14
|
+
import type { ConversationSummary } from '../providers/types'
|
|
15
|
+
import type { ConversationsWorkspaceLabels } from './labels'
|
|
16
|
+
import type { UseConversationsInboxResult } from './useConversationsInbox'
|
|
17
|
+
|
|
18
|
+
export interface ConversationsInboxListProps {
|
|
19
|
+
readonly inbox: UseConversationsInboxResult
|
|
20
|
+
readonly labels: ConversationsWorkspaceLabels
|
|
21
|
+
readonly className?: string
|
|
22
|
+
/** Barra extra do produto acima da lista (ex.: filtro de carteira, seletor de campanha). */
|
|
23
|
+
readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
24
|
+
/** Ações em lote do produto, ao lado de finalizar e template. */
|
|
25
|
+
readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
|
|
26
|
+
readonly renderRow?: (conversation: ConversationSummary) => ReactNode
|
|
27
|
+
readonly onSendTemplateToSelected?: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ConversationsInboxList({
|
|
31
|
+
inbox,
|
|
32
|
+
labels,
|
|
33
|
+
className,
|
|
34
|
+
renderFilters,
|
|
35
|
+
renderBulkActions,
|
|
36
|
+
renderRow,
|
|
37
|
+
onSendTemplateToSelected,
|
|
38
|
+
}: ConversationsInboxListProps) {
|
|
39
|
+
const hasSelection = inbox.selectedIds.size > 0
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<aside className={className}>
|
|
43
|
+
{hasSelection ? (
|
|
44
|
+
<div className="cv-workspace-bulk">
|
|
45
|
+
<span className="cv-workspace-bulk__count">{labels.bulkSelected(inbox.selectedIds.size)}</span>
|
|
46
|
+
<div className="cv-workspace-bulk__actions">
|
|
47
|
+
<button type="button" onClick={inbox.clearBulkSelection}>
|
|
48
|
+
{labels.bulkClear}
|
|
49
|
+
</button>
|
|
50
|
+
{onSendTemplateToSelected ? (
|
|
51
|
+
<button type="button" onClick={onSendTemplateToSelected} disabled={inbox.busy}>
|
|
52
|
+
{labels.bulkTemplate}
|
|
53
|
+
</button>
|
|
54
|
+
) : null}
|
|
55
|
+
{inbox.canFinalize ? (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
disabled={inbox.busy}
|
|
59
|
+
onClick={() => {
|
|
60
|
+
// Confirmação porque finalizar em lote não tem desfazer: encerra o atendimento de
|
|
61
|
+
// todas as conversas marcadas de uma vez.
|
|
62
|
+
if (window.confirm(labels.bulkFinalizeConfirm(inbox.selectedIds.size))) {
|
|
63
|
+
void inbox.finalizeSelected()
|
|
64
|
+
}
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{labels.bulkFinalize}
|
|
68
|
+
</button>
|
|
69
|
+
) : null}
|
|
70
|
+
{renderBulkActions?.(inbox)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
) : null}
|
|
74
|
+
|
|
75
|
+
<div className="cv-workspace-filters">
|
|
76
|
+
<input
|
|
77
|
+
type="search"
|
|
78
|
+
value={inbox.search}
|
|
79
|
+
onChange={(event) => inbox.setSearch(event.target.value)}
|
|
80
|
+
placeholder={labels.search}
|
|
81
|
+
className="cv-workspace-search"
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
<div className="cv-workspace-chips">
|
|
85
|
+
<span className="cv-workspace-chips__legend">{labels.windowLegend}</span>
|
|
86
|
+
{WINDOW_FILTERS.map((filter) => (
|
|
87
|
+
<button
|
|
88
|
+
key={filter.value}
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => inbox.setWindowFilter(filter.value)}
|
|
91
|
+
aria-pressed={inbox.windowFilter === filter.value}
|
|
92
|
+
className={`cv-workspace-chip${inbox.windowFilter === filter.value ? ' cv-workspace-chip--on' : ''}`}
|
|
93
|
+
>
|
|
94
|
+
{filter.dotClass ? <span className={`cv-workspace-dot ${filter.dotClass}`} /> : null}
|
|
95
|
+
{filter.label}
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Some quando há um canal só: filtro de opção única não filtra nada. */}
|
|
101
|
+
{inbox.channelFilters.length > 0 ? (
|
|
102
|
+
<div className="cv-workspace-chips">
|
|
103
|
+
<span className="cv-workspace-chips__legend">{labels.channelLegend}</span>
|
|
104
|
+
{inbox.channelFilters.map((filter) => (
|
|
105
|
+
<button
|
|
106
|
+
key={filter.value}
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => inbox.setChannelFilter(filter.value)}
|
|
109
|
+
aria-pressed={inbox.channelFilter === filter.value}
|
|
110
|
+
className={`cv-workspace-chip${inbox.channelFilter === filter.value ? ' cv-workspace-chip--on' : ''}`}
|
|
111
|
+
>
|
|
112
|
+
{filter.value === CHANNEL_FILTER_ALL ? null : <ChannelIcon channel={filter.value} />}
|
|
113
|
+
{filter.label}
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
) : null}
|
|
118
|
+
|
|
119
|
+
{renderFilters?.(inbox)}
|
|
120
|
+
|
|
121
|
+
<label className="cv-workspace-selectall">
|
|
122
|
+
<input type="checkbox" checked={inbox.allOnPageSelected} onChange={inbox.toggleSelectAllOnPage} />
|
|
123
|
+
{labels.selectAll}
|
|
124
|
+
</label>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="cv-workspace-rows">
|
|
128
|
+
{inbox.pageConversations.map((conversation) =>
|
|
129
|
+
renderRow ? (
|
|
130
|
+
<div key={conversation.id}>{renderRow(conversation)}</div>
|
|
131
|
+
) : (
|
|
132
|
+
<ConversationRow
|
|
133
|
+
key={conversation.id}
|
|
134
|
+
conversation={conversation}
|
|
135
|
+
active={conversation.id === inbox.selectedId}
|
|
136
|
+
selected={inbox.selectedIds.has(conversation.id)}
|
|
137
|
+
now={inbox.now}
|
|
138
|
+
busy={inbox.busy}
|
|
139
|
+
onOpen={() => inbox.selectConversation(conversation.id)}
|
|
140
|
+
onToggleSelected={() => inbox.toggleSelected(conversation.id)}
|
|
141
|
+
onTakeover={() => void inbox.takeover(conversation.id)}
|
|
142
|
+
/>
|
|
143
|
+
),
|
|
144
|
+
)}
|
|
145
|
+
{!inbox.loading && inbox.filteredCount === 0 ? (
|
|
146
|
+
<p className="cv-workspace-empty">{labels.emptyList}</p>
|
|
147
|
+
) : null}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div className="cv-workspace-pager">
|
|
151
|
+
<span>{labels.rangeOf(inbox.firstOnPage, inbox.lastOnPage, inbox.filteredCount)}</span>
|
|
152
|
+
<div className="cv-workspace-pager__buttons">
|
|
153
|
+
<button type="button" onClick={() => inbox.goToPage(1)} disabled={inbox.page === 1} aria-label="Primeira página">
|
|
154
|
+
«
|
|
155
|
+
</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={() => inbox.goToPage(inbox.page - 1)}
|
|
159
|
+
disabled={inbox.page === 1}
|
|
160
|
+
aria-label="Página anterior"
|
|
161
|
+
>
|
|
162
|
+
‹
|
|
163
|
+
</button>
|
|
164
|
+
<span>{labels.pageOf(inbox.page, inbox.pageCount)}</span>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
onClick={() => inbox.goToPage(inbox.page + 1)}
|
|
168
|
+
disabled={inbox.page === inbox.pageCount}
|
|
169
|
+
aria-label="Próxima página"
|
|
170
|
+
>
|
|
171
|
+
›
|
|
172
|
+
</button>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => inbox.goToPage(inbox.pageCount)}
|
|
176
|
+
disabled={inbox.page === inbox.pageCount}
|
|
177
|
+
aria-label="Última página"
|
|
178
|
+
>
|
|
179
|
+
»
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</aside>
|
|
184
|
+
)
|
|
185
|
+
}
|