@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.
Files changed (143) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/chunk-GY472G6E.js +2316 -0
  3. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  4. package/dist/flows/index.d.ts +311 -4
  5. package/dist/flows/index.js +1322 -55
  6. package/dist/index.d.ts +1074 -42
  7. package/dist/index.js +3571 -742
  8. package/dist/preview/index.d.ts +328 -8
  9. package/dist/preview/index.js +902 -115
  10. package/dist/styles.css +819 -0
  11. package/dist/types-De5aN-E_.d.ts +502 -0
  12. package/package.json +3 -3
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +42 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DocumentsLibrary.tsx +382 -0
  27. package/src/EmojiPicker.tsx +70 -55
  28. package/src/FileIcon.test.ts +83 -0
  29. package/src/FileIcon.tsx +88 -11
  30. package/src/InteractiveMessage.test.tsx +41 -0
  31. package/src/InteractiveMessage.tsx +146 -0
  32. package/src/Lightbox.tsx +18 -3
  33. package/src/MediaRenderer.tsx +98 -22
  34. package/src/MessageBubble.test.tsx +41 -0
  35. package/src/MessageBubble.tsx +75 -6
  36. package/src/MessageComposer.test.tsx +35 -0
  37. package/src/MessageComposer.tsx +155 -19
  38. package/src/RichMessageComposer.test.tsx +113 -0
  39. package/src/RichMessageComposer.tsx +551 -0
  40. package/src/SimpleEmojiPicker.tsx +5 -3
  41. package/src/StatusTicks.tsx +1 -1
  42. package/src/Toast.tsx +4 -0
  43. package/src/Tooltip.test.ts +42 -0
  44. package/src/Tooltip.tsx +164 -0
  45. package/src/Wallpaper.test.tsx +21 -0
  46. package/src/Wallpaper.tsx +67 -7
  47. package/src/WhatsAppMessageEditor.tsx +28 -4
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/audioRecorderFormat.test.ts +67 -0
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +543 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/emojiCatalog.test.ts +35 -0
  60. package/src/emojiCatalog.ts +189 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +35 -8
  65. package/src/flows/FlowNodePanel.tsx +149 -38
  66. package/src/flows/FlowPalette.tsx +13 -3
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1003 -0
  70. package/src/flows/flowCanvasModel.test.ts +293 -0
  71. package/src/flows/flowCanvasModel.ts +342 -0
  72. package/src/flows/flowEditorOps.test.ts +241 -0
  73. package/src/flows/flowEditorOps.ts +177 -0
  74. package/src/flows/flowGraph.ts +6 -6
  75. package/src/flows/index.ts +40 -1
  76. package/src/flows/labels.ts +141 -0
  77. package/src/flows/workspaceContract.test.ts +95 -0
  78. package/src/hooks/useContainerWidth.ts +35 -0
  79. package/src/hooks/useConversationActions.ts +56 -0
  80. package/src/hooks/useConversationDocuments.ts +11 -7
  81. package/src/hooks/useConversationList.ts +15 -9
  82. package/src/hooks/useConversationMessages.ts +2 -2
  83. package/src/hooks/useConversationRealtime.ts +10 -8
  84. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  85. package/src/hooks/useUrlFilterState.ts +107 -0
  86. package/src/icon.constant.ts +12 -0
  87. package/src/index.ts +114 -13
  88. package/src/lib/cn.test.ts +29 -0
  89. package/src/lib/composer-formatting.test.ts +78 -0
  90. package/src/lib/composer-formatting.ts +145 -0
  91. package/src/lib/createMediaUrlResolver.ts +33 -0
  92. package/src/lib/paginated.test.ts +33 -0
  93. package/src/lib/paginated.ts +26 -0
  94. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  95. package/src/lib/whatsapp-formatting.tsx +28 -3
  96. package/src/listing/index.tsx +202 -0
  97. package/src/pagination.constant.ts +10 -0
  98. package/src/preview/ConversationPreview.tsx +199 -13
  99. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  100. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  101. package/src/preview/MediaTypesPreview.tsx +87 -0
  102. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  103. package/src/preview/createMockConversationsApi.ts +175 -15
  104. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  105. package/src/preview/createPreviewBridgeClient.ts +124 -0
  106. package/src/preview/createPreviewMediaUploader.ts +82 -0
  107. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  108. package/src/preview/createPreviewWebhookClient.ts +127 -4
  109. package/src/preview/index.ts +33 -3
  110. package/src/preview/mediaTypeOf.test.ts +15 -0
  111. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  112. package/src/preview/preview.test.ts +5 -3
  113. package/src/preview/previewFileSamples.test.ts +151 -0
  114. package/src/preview/previewFileSamples.ts +74 -0
  115. package/src/preview/previewFixtures.ts +288 -1
  116. package/src/preview/previewMediaSource.test.ts +62 -0
  117. package/src/preview/previewMediaSource.ts +91 -0
  118. package/src/preview/previewMediaUploader.test.ts +61 -0
  119. package/src/providers/ConversationsProvider.tsx +8 -6
  120. package/src/providers/types.ts +185 -10
  121. package/src/quickReply.test.ts +58 -0
  122. package/src/settings/MessagesWorkspace.tsx +468 -0
  123. package/src/settings/TopicsForm.tsx +2 -0
  124. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  125. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  126. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  127. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  128. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  129. package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
  130. package/src/styles.css +783 -0
  131. package/src/types.ts +64 -1
  132. package/src/useWaitingNotifications.ts +74 -29
  133. package/src/workspace/BulkTemplateModal.tsx +132 -0
  134. package/src/workspace/ConversationPane.tsx +432 -0
  135. package/src/workspace/ConversationsInboxList.tsx +194 -0
  136. package/src/workspace/ConversationsWorkspace.tsx +346 -0
  137. package/src/workspace/index.ts +12 -0
  138. package/src/workspace/labels.test.ts +17 -0
  139. package/src/workspace/labels.ts +85 -0
  140. package/src/workspace/useConversationsInbox.ts +332 -0
  141. package/dist/chunk-N7B24WYD.js +0 -719
  142. package/dist/chunk-NV2RZ5KT.js +0 -56
  143. 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
+ }