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