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