@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/types-B5C1DLu1.d.ts +0 -365
- 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
|
+
})
|