@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.0

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 (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  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 +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  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 +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,194 @@
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
+ import { PAGINATION_LABELS } from '../pagination.constant'
18
+
19
+ export interface ConversationsInboxListProps {
20
+ readonly inbox: UseConversationsInboxResult
21
+ readonly labels: ConversationsWorkspaceLabels
22
+ readonly className?: string
23
+ /** Barra extra do produto acima da lista (ex.: filtro de carteira, seletor de campanha). */
24
+ readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
25
+ /** Ações em lote do produto, ao lado de finalizar e template. */
26
+ readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
27
+ readonly renderRow?: (conversation: ConversationSummary) => ReactNode
28
+ readonly onSendTemplateToSelected?: () => void
29
+ }
30
+
31
+ export function ConversationsInboxList({
32
+ inbox,
33
+ labels,
34
+ className,
35
+ renderFilters,
36
+ renderBulkActions,
37
+ renderRow,
38
+ onSendTemplateToSelected,
39
+ }: ConversationsInboxListProps) {
40
+ const hasSelection = inbox.selectedIds.size > 0
41
+
42
+ return (
43
+ <aside className={className}>
44
+ {hasSelection ? (
45
+ <div className="cv-workspace-bulk">
46
+ <span className="cv-workspace-bulk__count">{labels.bulkSelected(inbox.selectedIds.size)}</span>
47
+ <div className="cv-workspace-bulk__actions">
48
+ <button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={inbox.clearBulkSelection}>
49
+ {labels.bulkClear}
50
+ </button>
51
+ {onSendTemplateToSelected ? (
52
+ <button data-cv-tooltip={labels.bulkTemplate} aria-label={labels.bulkTemplate} type="button" onClick={onSendTemplateToSelected} disabled={inbox.busy}>
53
+ {labels.bulkTemplate}
54
+ </button>
55
+ ) : null}
56
+ {inbox.canFinalize ? (
57
+ <button
58
+ data-cv-tooltip={labels.bulkFinalize} aria-label={labels.bulkFinalize}
59
+ type="button"
60
+ disabled={inbox.busy}
61
+ onClick={() => {
62
+ // Confirmação porque finalizar em lote não tem desfazer: encerra o atendimento de
63
+ // todas as conversas marcadas de uma vez.
64
+ if (window.confirm(labels.bulkFinalizeConfirm(inbox.selectedIds.size))) {
65
+ void inbox.finalizeSelected()
66
+ }
67
+ }}
68
+ >
69
+ {labels.bulkFinalize}
70
+ </button>
71
+ ) : null}
72
+ {renderBulkActions?.(inbox)}
73
+ </div>
74
+ </div>
75
+ ) : null}
76
+
77
+ <div className="cv-workspace-filters">
78
+ <input
79
+ type="search"
80
+ value={inbox.search}
81
+ onChange={(event) => inbox.setSearch(event.target.value)}
82
+ placeholder={labels.search}
83
+ className="cv-workspace-search"
84
+ />
85
+
86
+ <div className="cv-workspace-chips">
87
+ <span className="cv-workspace-chips__legend">{labels.windowLegend}</span>
88
+ {WINDOW_FILTERS.map((filter) => (
89
+ <button
90
+ key={filter.value}
91
+ type="button"
92
+ onClick={() => inbox.setWindowFilter(filter.value)}
93
+ aria-pressed={inbox.windowFilter === filter.value}
94
+ data-cv-tooltip={filter.label} aria-label={filter.label}
95
+ className={[
96
+ 'cv-workspace-chip',
97
+ filter.tone ? `cv-workspace-chip--${filter.tone}` : '',
98
+ inbox.windowFilter === filter.value ? 'cv-workspace-chip--on' : '',
99
+ ]
100
+ .filter(Boolean)
101
+ .join(' ')}
102
+ >
103
+ {filter.label}
104
+ </button>
105
+ ))}
106
+ </div>
107
+
108
+ {/* Some quando há um canal só: filtro de opção única não filtra nada. */}
109
+ {inbox.channelFilters.length > 0 ? (
110
+ <div className="cv-workspace-chips">
111
+ <span className="cv-workspace-chips__legend">{labels.channelLegend}</span>
112
+ {inbox.channelFilters.map((filter) => (
113
+ <button
114
+ data-cv-tooltip={filter.label} aria-label={filter.label}
115
+ key={filter.value}
116
+ type="button"
117
+ onClick={() => inbox.setChannelFilter(filter.value)}
118
+ aria-pressed={inbox.channelFilter === filter.value}
119
+ className={`cv-workspace-chip${inbox.channelFilter === filter.value ? ' cv-workspace-chip--on' : ''}`}
120
+ >
121
+ {filter.value === CHANNEL_FILTER_ALL ? null : <ChannelIcon channel={filter.value} />}
122
+ {filter.label}
123
+ </button>
124
+ ))}
125
+ </div>
126
+ ) : null}
127
+
128
+ {renderFilters?.(inbox)}
129
+
130
+ <label className="cv-workspace-selectall">
131
+ <input type="checkbox" checked={inbox.allOnPageSelected} onChange={inbox.toggleSelectAllOnPage} />
132
+ {labels.selectAll}
133
+ </label>
134
+ </div>
135
+
136
+ <div className="cv-workspace-rows">
137
+ {inbox.pageConversations.map((conversation) =>
138
+ renderRow ? (
139
+ <div key={conversation.id}>{renderRow(conversation)}</div>
140
+ ) : (
141
+ <ConversationRow
142
+ key={conversation.id}
143
+ conversation={conversation}
144
+ active={conversation.id === inbox.selectedId}
145
+ selected={inbox.selectedIds.has(conversation.id)}
146
+ now={inbox.now}
147
+ busy={inbox.busy}
148
+ onOpen={() => inbox.selectConversation(conversation.id)}
149
+ onToggleSelected={() => inbox.toggleSelected(conversation.id)}
150
+ onTakeover={() => void inbox.takeover(conversation.id)}
151
+ />
152
+ ),
153
+ )}
154
+ {!inbox.loading && inbox.filteredCount === 0 ? (
155
+ <p className="cv-workspace-empty">{labels.emptyList}</p>
156
+ ) : null}
157
+ </div>
158
+
159
+ <div className="cv-workspace-pager">
160
+ <span>{labels.rangeOf(inbox.firstOnPage, inbox.lastOnPage, inbox.filteredCount)}</span>
161
+ <div className="cv-workspace-pager__buttons">
162
+ <button type="button" onClick={() => inbox.goToPage(1)} disabled={inbox.page === 1} aria-label={PAGINATION_LABELS.first} data-cv-tooltip={PAGINATION_LABELS.first}>
163
+ «
164
+ </button>
165
+ <button
166
+ type="button"
167
+ onClick={() => inbox.goToPage(inbox.page - 1)}
168
+ disabled={inbox.page === 1}
169
+ aria-label={PAGINATION_LABELS.previous} data-cv-tooltip={PAGINATION_LABELS.previous}
170
+ >
171
+
172
+ </button>
173
+ <span>{labels.pageOf(inbox.page, inbox.pageCount)}</span>
174
+ <button
175
+ type="button"
176
+ onClick={() => inbox.goToPage(inbox.page + 1)}
177
+ disabled={inbox.page === inbox.pageCount}
178
+ aria-label={PAGINATION_LABELS.next} data-cv-tooltip={PAGINATION_LABELS.next}
179
+ >
180
+
181
+ </button>
182
+ <button
183
+ type="button"
184
+ onClick={() => inbox.goToPage(inbox.pageCount)}
185
+ disabled={inbox.page === inbox.pageCount}
186
+ aria-label={PAGINATION_LABELS.last} data-cv-tooltip={PAGINATION_LABELS.last}
187
+ >
188
+ »
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </aside>
193
+ )
194
+ }
@@ -0,0 +1,423 @@
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
+ * O simulador é montado aqui, e não pelo host: com `simulator.transports` o produto entrega só o
14
+ * transporte de cada canal e a moldura vem do pacote. Isso traz o `preview/` para o bundle de quem
15
+ * usa o workspace — o preço de a tela composta ser o padrão de consumo, que é o que impede a inbox
16
+ * de divergir entre produtos.
17
+ */
18
+
19
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
20
+ import { Check, CheckCheck, FlaskConical, Hourglass, Mail, MessagesSquare } from 'lucide-react'
21
+
22
+ import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from '../icon.constant'
23
+ import type { ConversationContextEntry } from '../ConversationContextPanel'
24
+ import type { ConversationHeaderUtility } from '../ConversationHeader'
25
+ import type { QuickReply } from '../MessageComposer'
26
+ import type { RichComposerVariable } from '../RichMessageComposer'
27
+ import type { ConversationSummary } from '../providers/types'
28
+ import { useConversations } from '../providers/ConversationsProvider'
29
+ import { DEFAULT_CONVERSATION_CHANNEL, formatContactHandle, type ConversationChannel } from '../conversationChannel'
30
+ import type { ConversationSimulatorClient } from '../preview/ConversationSimulatorClient'
31
+ import type { PreviewUploadedMedia } from '../preview/createPreviewMediaUploader'
32
+ import {
33
+ ConversationSimulatorPanel,
34
+ type ConversationSimulatorPanelLabels,
35
+ } from '../preview/ConversationSimulatorPanel'
36
+ import { BulkTemplateModal } from './BulkTemplateModal'
37
+ import { ConversationPane } from './ConversationPane'
38
+ import { ConversationsInboxList } from './ConversationsInboxList'
39
+ import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabels } from './labels'
40
+ import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
41
+ import { TooltipLayer } from '../Tooltip'
42
+
43
+ export type SimulatorTransportParams = {
44
+ readonly conversationId: string
45
+ readonly channel: ConversationChannel
46
+ /** Identificador do contato no canal: telefone no WhatsApp, id de sessão no chat do site. */
47
+ readonly handle: string
48
+ }
49
+
50
+ /**
51
+ * Fábrica do transporte daquele canal.
52
+ *
53
+ * É o único ponto onde o host precisa saber de canal: o painel, a moldura e o comportamento são os
54
+ * mesmos em todos. No WhatsApp devolve o cliente-ponte (assinatura no servidor do host); no chat do
55
+ * site, o cliente das rotas do widget.
56
+ */
57
+ export type SimulatorTransportFactory = (params: SimulatorTransportParams) => ConversationSimulatorClient
58
+
59
+ export interface ConversationsWorkspaceSimulator {
60
+ /**
61
+ * Um transporte por canal — o workspace monta o painel com o da conversa selecionada.
62
+ *
63
+ * Canal sem transporte não desenha o botão: capacidade é opcional por ausência, e oferecer
64
+ * "simular" numa conversa que não tem como receber a mensagem é um botão que falha ao ser tocado.
65
+ */
66
+ readonly transports?: Partial<Record<ConversationChannel, SimulatorTransportFactory>>
67
+ /**
68
+ * Válvula de escape: o host desenha o painel inteiro. Tem precedência sobre `transports`.
69
+ *
70
+ * Era a única porta antes de `transports`, quando o simulador só falava WhatsApp e cada produto
71
+ * remontava a moldura — o que fez duas telas da mesma casa divergirem. Continua aceito para não
72
+ * quebrar quem já a usa.
73
+ */
74
+ render?(params: { conversationId: string; channel: ConversationChannel; close: () => void }): ReactNode
75
+ /** Ausente = ligado. Serve para esconder fora de desenvolvimento sem condicionar o JSX. */
76
+ readonly enabled?: boolean
77
+ /** Ícone da biblioteca (lucide) no utilitário do cabeçalho. Ausente, entra o frasco de teste. */
78
+ readonly icon?: ReactNode
79
+ readonly label?: string
80
+ /** Vocabulário do painel. O que muda de canal (destino, placeholder) já vem resolvido. */
81
+ readonly labels?: Partial<ConversationSimulatorPanelLabels>
82
+ /** Destino do upload no canal que entrega mídia por referência (o caminho da Meta). */
83
+ readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
84
+ /** Recarrega o transcript a cada N ms. Serve a host sem stream. */
85
+ readonly pollIntervalMs?: number
86
+ }
87
+
88
+ export interface ConversationsWorkspaceProps {
89
+ readonly labels?: Partial<ConversationsWorkspaceLabels>
90
+ readonly filters?: Record<string, string | undefined>
91
+ readonly perPage?: number
92
+ readonly markReadOnOpen?: boolean
93
+ /** Pede a página ao servidor em vez de fatiar no cliente. */
94
+ readonly serverPaginated?: boolean
95
+ /** Conversa a abrir na montagem (deep link `?id=`). */
96
+ readonly initialConversationId?: string | undefined
97
+ /** Idem, pelo telefone — é o que costuma vir no link de um alerta ou de um pedido. */
98
+ readonly initialWhatsappNumber?: string | undefined
99
+ readonly simulator?: ConversationsWorkspaceSimulator
100
+ readonly quickReplies?: readonly QuickReply[]
101
+ readonly quickReplyVariablesFor?: (
102
+ conversation: ConversationSummary,
103
+ context: Record<string, unknown> | undefined,
104
+ ) => Record<string, string>
105
+ /** Etapa do fluxo mostrada no painel de contexto. */
106
+ readonly flowLabelOf?: (conversation: ConversationSummary) => string | undefined
107
+ /** Substitui o download local do transcript (ex.: exportação completa pela rota do servidor). */
108
+ readonly onDownload?: (conversation: ConversationSummary) => void
109
+ /** Bloqueia o composer enquanto a conversa estiver com o bot. */
110
+ readonly requireTakeoverToReply?: boolean
111
+ /** Deixa marcar mensagens no transcript e copiar o trecho. */
112
+ readonly messageSelection?: boolean
113
+ /** Texto já no campo ao abrir a conversa (deep link que sugere a resposta). */
114
+ readonly initialComposerText?: string | undefined
115
+ /** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
116
+ readonly composer?: 'simple' | 'rich'
117
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
118
+ readonly composerVariablesFor?: (
119
+ conversation: ConversationSummary,
120
+ context: Record<string, unknown> | undefined,
121
+ ) => readonly RichComposerVariable[]
122
+ /** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
123
+ readonly onSendAttachments?: (
124
+ conversation: ConversationSummary,
125
+ files: readonly File[],
126
+ caption: string,
127
+ ) => Promise<void>
128
+ /** Nota de voz. Ausente, o microfone não aparece. */
129
+ readonly onRecordAudio?: (conversation: ConversationSummary, file: File) => Promise<void>
130
+ readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
131
+ readonly onAttach?: (conversation: ConversationSummary, file: File) => Promise<void>
132
+ readonly extraUtilitiesFor?: (conversation: ConversationSummary) => readonly ConversationHeaderUtility[]
133
+ readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
134
+ readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
135
+ readonly renderRow?: (conversation: ConversationSummary) => ReactNode
136
+ readonly renderAboveTranscript?: (
137
+ conversation: ConversationSummary,
138
+ context: Record<string, unknown> | undefined,
139
+ ) => ReactNode
140
+ readonly renderHeaderActions?: (inbox: UseConversationsInboxResult) => ReactNode
141
+ readonly onSendTemplateToSelected?: (inbox: UseConversationsInboxResult) => void
142
+ /** Destino do link de reentrar no painel, mostrado junto do aviso de sessão expirada. */
143
+ readonly signInHref?: string
144
+ readonly className?: string
145
+ }
146
+
147
+ export function ConversationsWorkspace({
148
+ labels: labelsOverride,
149
+ filters,
150
+ perPage,
151
+ markReadOnOpen,
152
+ serverPaginated,
153
+ initialConversationId,
154
+ initialWhatsappNumber,
155
+ simulator,
156
+ quickReplies,
157
+ quickReplyVariablesFor,
158
+ flowLabelOf,
159
+ onDownload,
160
+ requireTakeoverToReply,
161
+ messageSelection,
162
+ initialComposerText,
163
+ composer,
164
+ composerVariablesFor,
165
+ onSendAttachments,
166
+ onRecordAudio,
167
+ contextEntriesOf,
168
+ onAttach,
169
+ extraUtilitiesFor,
170
+ renderFilters,
171
+ renderBulkActions,
172
+ renderRow,
173
+ renderAboveTranscript,
174
+ renderHeaderActions,
175
+ onSendTemplateToSelected,
176
+ signInHref,
177
+ className,
178
+ }: ConversationsWorkspaceProps) {
179
+ const labels = { ...DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, ...labelsOverride }
180
+ const inbox = useConversationsInbox({
181
+ ...(filters ? { filters } : {}),
182
+ ...(perPage ? { perPage } : {}),
183
+ ...(markReadOnOpen === undefined ? {} : { markReadOnOpen }),
184
+ ...(serverPaginated ? { serverPaginated } : {}),
185
+ })
186
+ const [simulatorOpen, setSimulatorOpen] = useState(false)
187
+ const [templateModalOpen, setTemplateModalOpen] = useState(false)
188
+ const [openedFromLink, setOpenedFromLink] = useState<string | undefined>(undefined)
189
+
190
+ // Só seleciona depois que a conversa aparece na lista: o hook limpa qualquer seleção que não
191
+ // esteja em `conversations`, e no primeiro render a lista ainda está vazia.
192
+ useEffect(() => {
193
+ const link = initialConversationId ?? initialWhatsappNumber
194
+ if (!link || openedFromLink === link) return
195
+ const target = inbox.conversations.find(
196
+ (conversation) =>
197
+ conversation.id === initialConversationId || conversation.whatsappNumber === initialWhatsappNumber,
198
+ )
199
+ if (!target) return
200
+ inbox.selectConversation(target.id)
201
+ setOpenedFromLink(link)
202
+ }, [initialConversationId, initialWhatsappNumber, openedFromLink, inbox])
203
+
204
+ const selected = inbox.selectedConversation
205
+ const conversations = useConversations()
206
+ const selectedId = selected?.id
207
+ const channel = selected?.channel ?? DEFAULT_CONVERSATION_CHANNEL
208
+ const handle = selected ? (selected.contactId ?? selected.whatsappNumber) : ''
209
+ const transport = simulator?.transports?.[channel]
210
+ const simulatorEnabled = Boolean(
211
+ simulator && (simulator.enabled ?? true) && (simulator.render ?? transport),
212
+ )
213
+ const showSimulator = simulatorEnabled && simulatorOpen && Boolean(selected)
214
+
215
+ // O transporte é recriado só quando a conversa (ou o canal dela) muda: identidade nova a cada
216
+ // render reiniciaria a leitura do transcript dentro do painel a cada digitação.
217
+ const simulatorClient = useMemo(
218
+ () => (transport && selectedId ? transport({ conversationId: selectedId, channel, handle }) : undefined),
219
+ [transport, selectedId, channel, handle],
220
+ )
221
+
222
+ const paneUtilities = useMemo(() => {
223
+ if (!selected) return undefined
224
+ const fromProduct = extraUtilitiesFor?.(selected) ?? []
225
+ if (!simulatorEnabled) return fromProduct
226
+ // No cabeçalho da conversa, ao lado de "Assumir atendimento": o simulador age sobre ESTA
227
+ // conversa; no cabeçalho da página ele parecia um filtro da inbox.
228
+ return [
229
+ ...fromProduct,
230
+ {
231
+ key: 'simulator',
232
+ icon: simulator?.icon ?? <FlaskConical size={ICON_SIZE_ACTION} />,
233
+ label: simulator?.label ?? 'Simular cliente',
234
+ active: simulatorOpen,
235
+ run: () => setSimulatorOpen((open) => !open),
236
+ },
237
+ ]
238
+ }, [selected, extraUtilitiesFor, simulatorEnabled, simulator, simulatorOpen])
239
+
240
+ return (
241
+ <div className={`cv-workspace${className ? ` ${className}` : ''}`}>
242
+ <TooltipLayer />
243
+ {/* Cabeçalho denso em tela estreita: ícone + número. O rótulo escrito ocupava três linhas em
244
+ 375px e empurrava a lista para fora da tela. */}
245
+ <header className="cv-workspace-header">
246
+ <div className="cv-workspace-header__titles">
247
+ <h1>{labels.title}</h1>
248
+ <p>
249
+ <span data-cv-tooltip={labels.conversations} className="cv-stat">
250
+ <MessagesSquare size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.totalCount}
251
+ <span className="cv-only-wide"> {labels.conversations}</span>
252
+ </span>
253
+ <span data-cv-tooltip={labels.waiting} className="cv-stat cv-workspace-header__waiting">
254
+ <Hourglass size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.waitingCount}
255
+ <span className="cv-only-wide"> {labels.waiting}</span>
256
+ </span>
257
+ <span data-cv-tooltip={labels.unread} className="cv-stat">
258
+ <Mail size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.unreadCount}
259
+ <span className="cv-only-wide"> {labels.unread}</span>
260
+ </span>
261
+ </p>
262
+ </div>
263
+
264
+ <div className="cv-workspace-header__actions">
265
+ <button
266
+ type="button"
267
+ onClick={() => inbox.setWaitingOnly(!inbox.waitingOnly)}
268
+ aria-pressed={inbox.waitingOnly}
269
+ data-cv-tooltip={labels.waitingOnly} aria-label={labels.waitingOnly}
270
+ className={inbox.waitingOnly ? 'cv-workspace-toggle cv-workspace-toggle--on' : 'cv-workspace-toggle'}
271
+ >
272
+ <Hourglass size={ICON_SIZE_ACTION} aria-hidden="true" />
273
+ <span className="cv-only-wide">{labels.waitingOnly}</span>
274
+ </button>
275
+ {/* O contador só aparece com seleção: " (0)" era texto morto ao lado do ícone. */}
276
+ <button
277
+ type="button"
278
+ onClick={() => void inbox.markSelectedAsRead()}
279
+ disabled={inbox.selectedIds.size === 0 || inbox.busy}
280
+ data-cv-tooltip={labels.markSelectedAsRead}
281
+ aria-label={labels.markSelectedAsRead}
282
+ className="cv-workspace-toggle"
283
+ >
284
+ <Check size={ICON_SIZE_ACTION} aria-hidden="true" />
285
+ <span className="cv-only-wide">{labels.markSelectedAsRead}</span>
286
+ {inbox.selectedIds.size > 0 ? <span>({inbox.selectedIds.size})</span> : null}
287
+ </button>
288
+ {/* Só com não lida na tela e só onde o host implementa a rota: sem isso o botão zerava
289
+ nada e ainda assim ocupava o cabeçalho. */}
290
+ {inbox.canMarkAllRead && inbox.unreadCount > 0 ? (
291
+ <button
292
+ type="button"
293
+ onClick={() => void inbox.markAllAsRead()}
294
+ disabled={inbox.busy}
295
+ data-cv-tooltip={labels.markAllAsRead} aria-label={labels.markAllAsRead}
296
+ className="cv-workspace-toggle"
297
+ >
298
+ <CheckCheck size={ICON_SIZE_ACTION} aria-hidden="true" />
299
+ <span className="cv-only-wide">{labels.markAllAsRead}</span>
300
+ </button>
301
+ ) : null}
302
+ {renderHeaderActions?.(inbox)}
303
+ </div>
304
+ </header>
305
+
306
+ {/* Silêncio aqui foi o que fez "não existe nenhuma conversa" parecer perda de dados: sem
307
+ sessão a API responde 401, a lista vem vazia e a tela não dizia nada. */}
308
+ {inbox.loadFailure ? (
309
+ <p role="alert" className="cv-workspace-failure">
310
+ {inbox.loadFailure}
311
+ {signInHref ? (
312
+ <>
313
+ {' '}
314
+ <a href={signInHref} className="cv-workspace-failure__link">
315
+ {labels.signIn}
316
+ </a>
317
+ </>
318
+ ) : null}
319
+ </p>
320
+ ) : null}
321
+
322
+ {/* Master/detail: em tela estreita a grade empilhava lista e conversa na mesma altura fixa e
323
+ cada uma virava uma fatia inútil — abaixo de `lg` mostra uma ou outra. As três colunas só
324
+ convivem a partir de `xl`; entre `lg` e `xl` a lista sai, porque enquanto se testa UMA
325
+ conversa é ela que menos importa. Cada coluna é um cartão com respiro entre elas:
326
+ separação por espaço lê mais rápido que por borda. */}
327
+ <div
328
+ className={`cv-workspace-grid${showSimulator ? ' cv-workspace-grid--with-simulator' : ''}`}
329
+ data-detail={selected ? 'open' : 'closed'}
330
+ >
331
+ <ConversationsInboxList
332
+ inbox={inbox}
333
+ labels={labels}
334
+ className="cv-workspace-list"
335
+ {...(renderFilters ? { renderFilters } : {})}
336
+ {...(renderBulkActions ? { renderBulkActions } : {})}
337
+ {...(renderRow ? { renderRow } : {})}
338
+ {...(onSendTemplateToSelected
339
+ ? { onSendTemplateToSelected: () => onSendTemplateToSelected(inbox) }
340
+ : inbox.canListTemplates
341
+ ? { onSendTemplateToSelected: () => setTemplateModalOpen(true) }
342
+ : {})}
343
+ />
344
+
345
+ {/* `section`, não `main`: o layout do host já provê o `main` da página. */}
346
+ <section className="cv-workspace-detail">
347
+ {selected ? (
348
+ <ConversationPane
349
+ conversation={selected}
350
+ now={inbox.now}
351
+ busy={inbox.busy}
352
+ labels={labels}
353
+ {...(inbox.canTakeover ? { onTakeover: () => inbox.takeover(selected.id) } : {})}
354
+ {...(inbox.canTakeover ? { onReturnToBot: () => void inbox.releaseToBot(selected.id) } : {})}
355
+ {...(inbox.canFinalize ? { onFinish: () => void inbox.finalize(selected.id) } : {})}
356
+ {...(flowLabelOf ? { flowLabel: flowLabelOf(selected) } : {})}
357
+ {...(onDownload ? { onDownload: () => onDownload(selected) } : {})}
358
+ {...(requireTakeoverToReply ? { requireTakeoverToReply } : {})}
359
+ {...(messageSelection ? { messageSelection } : {})}
360
+ {...(initialComposerText ? { initialComposerText } : {})}
361
+ {...(composer ? { composer } : {})}
362
+ {...(composerVariablesFor ? { composerVariablesFor } : {})}
363
+ {...(onSendAttachments
364
+ ? {
365
+ onSendAttachments: (files: readonly File[], caption: string) =>
366
+ onSendAttachments(selected, files, caption),
367
+ }
368
+ : {})}
369
+ {...(onRecordAudio ? { onRecordAudio: (file: File) => onRecordAudio(selected, file) } : {})}
370
+ onBack={inbox.clearSelection}
371
+ {...(paneUtilities ? { extraUtilities: paneUtilities } : {})}
372
+ {...(contextEntriesOf ? { contextEntriesOf } : {})}
373
+ {...(quickReplies ? { quickReplies } : {})}
374
+ {...(quickReplyVariablesFor ? { quickReplyVariablesFor } : {})}
375
+ {...(renderAboveTranscript ? { renderAboveTranscript } : {})}
376
+ {...(onAttach ? { onAttach: (file: File) => onAttach(selected, file) } : {})}
377
+ />
378
+ ) : (
379
+ <p className="cv-workspace-empty">{labels.emptyDetail}</p>
380
+ )}
381
+ </section>
382
+
383
+ {showSimulator && selected ? (
384
+ // `min-height:0` junto do `min-width:0`: sem isso a linha do grid cresce com o conteúdo do
385
+ // painel, o scroll interno nunca ativa e quem rola passa a ser a página inteira.
386
+ <div className="cv-workspace-simulator">
387
+ {simulator?.render ? (
388
+ simulator.render({ conversationId: selected.id, channel, close: () => setSimulatorOpen(false) })
389
+ ) : simulatorClient && conversations ? (
390
+ // `key` pela conversa: trocar de contato sem remontar deixaria o transcript e o campo
391
+ // de texto do contato anterior na tela.
392
+ <ConversationSimulatorPanel
393
+ key={selected.id}
394
+ client={simulatorClient}
395
+ sse={conversations.sse}
396
+ conversationId={selected.id}
397
+ channel={channel}
398
+ displayHandle={formatContactHandle({ handle, channel })}
399
+ loadMessages={(conversationId) => conversations.api.fetchMessages(conversationId)}
400
+ onClose={() => setSimulatorOpen(false)}
401
+ {...(simulator?.labels ? { labels: simulator.labels } : {})}
402
+ {...(simulator?.uploadMedia ? { uploadMedia: simulator.uploadMedia } : {})}
403
+ {...(simulator?.pollIntervalMs ? { pollIntervalMs: simulator.pollIntervalMs } : {})}
404
+ />
405
+ ) : null}
406
+ </div>
407
+ ) : null}
408
+ </div>
409
+
410
+ {templateModalOpen ? (
411
+ <BulkTemplateModal
412
+ labels={labels}
413
+ expiredCount={inbox.expiredSelectedCount}
414
+ sending={inbox.busy}
415
+ onClose={() => setTemplateModalOpen(false)}
416
+ onSend={(templateName) => {
417
+ void inbox.sendTemplateToSelected(templateName).then(() => setTemplateModalOpen(false))
418
+ }}
419
+ />
420
+ ) : null}
421
+ </div>
422
+ )
423
+ }
@@ -0,0 +1,17 @@
1
+ export { ConversationsWorkspace } from './ConversationsWorkspace'
2
+ export type {
3
+ ConversationsWorkspaceProps,
4
+ ConversationsWorkspaceSimulator,
5
+ SimulatorTransportFactory,
6
+ SimulatorTransportParams,
7
+ } from './ConversationsWorkspace'
8
+ export { ConversationPane } from './ConversationPane'
9
+ export type { ConversationPaneProps } from './ConversationPane'
10
+ export { BulkTemplateModal } from './BulkTemplateModal'
11
+ export type { BulkTemplateModalProps } from './BulkTemplateModal'
12
+ export { ConversationsInboxList } from './ConversationsInboxList'
13
+ export type { ConversationsInboxListProps } from './ConversationsInboxList'
14
+ export { useConversationsInbox, CONVERSATIONS_PER_PAGE } from './useConversationsInbox'
15
+ export type { UseConversationsInboxParams, UseConversationsInboxResult } from './useConversationsInbox'
16
+ export { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './labels'
17
+ 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
+ })