@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,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ConversationHeader } from './ConversationHeader'
|
|
5
|
+
import type { ConversationSummary } from './providers/types'
|
|
6
|
+
|
|
7
|
+
const noop = () => {}
|
|
8
|
+
|
|
9
|
+
const CONVERSATION: ConversationSummary = {
|
|
10
|
+
id: 'conv-1',
|
|
11
|
+
clientName: 'Anderson Silva',
|
|
12
|
+
whatsappNumber: '5511988887777',
|
|
13
|
+
channel: 'whatsapp',
|
|
14
|
+
mode: 'human',
|
|
15
|
+
assignedUserId: null,
|
|
16
|
+
waitingHuman: false,
|
|
17
|
+
unread: 0,
|
|
18
|
+
currentState: 'idle',
|
|
19
|
+
lastAt: '2026-01-01T12:00:00.000Z',
|
|
20
|
+
lastInboundAt: null,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('ConversationHeader sem medida da faixa', () => {
|
|
24
|
+
// Sem `ResizeObserver` (SSR e este teste de markup) o cabeçalho nasce completo. O recolhimento é
|
|
25
|
+
// medido no próprio elemento, e é verificado no navegador.
|
|
26
|
+
it('mostra utilitários e ações na faixa, sem duplicar no menu', () => {
|
|
27
|
+
const markup = renderToStaticMarkup(
|
|
28
|
+
<ConversationHeader
|
|
29
|
+
conversation={CONVERSATION}
|
|
30
|
+
onDownload={noop}
|
|
31
|
+
onOpenDocuments={noop}
|
|
32
|
+
onReturnToBot={noop}
|
|
33
|
+
onFinish={noop}
|
|
34
|
+
/>,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
expect(markup).toContain('aria-label="Baixar conversa"')
|
|
38
|
+
expect(markup).toContain('aria-label="Finalizar"')
|
|
39
|
+
expect(markup).not.toContain('aria-label="Mais ações"')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('escreve o canal e o modo por extenso quando há espaço', () => {
|
|
43
|
+
const markup = renderToStaticMarkup(<ConversationHeader conversation={CONVERSATION} />)
|
|
44
|
+
|
|
45
|
+
expect(markup).toContain('atendimento humano')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Emoji desenha diferente em cada sistema e não herda a cor do controle: os ícones do cabeçalho
|
|
49
|
+
// são SVG da biblioteca, como no resto do produto.
|
|
50
|
+
it('desenha os ícones como SVG, sem emoji no meio dos rótulos', () => {
|
|
51
|
+
const markup = renderToStaticMarkup(
|
|
52
|
+
<ConversationHeader
|
|
53
|
+
conversation={CONVERSATION}
|
|
54
|
+
onBack={noop}
|
|
55
|
+
onDownload={noop}
|
|
56
|
+
onOpenDocuments={noop}
|
|
57
|
+
onReturnToBot={noop}
|
|
58
|
+
onFinish={noop}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(markup).toContain('lucide-download')
|
|
63
|
+
expect(markup).not.toContain('✕')
|
|
64
|
+
expect(markup).not.toContain('⬇️')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -6,14 +6,29 @@
|
|
|
6
6
|
* papel do pacote saber a rota de cada host. Quem passa os handlers é o produto.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useState } from 'react'
|
|
9
|
+
import { useRef, useState, type ReactNode } from 'react'
|
|
10
|
+
import { ArrowLeft, Bot, Download, FileText, Headset, MoreVertical, UserRound, X } from 'lucide-react'
|
|
10
11
|
|
|
11
12
|
import type { ConversationSummary } from './providers/types'
|
|
12
13
|
import { capabilitiesOf, contactFlag, formatContactHandle } from './conversationChannel'
|
|
13
14
|
import { cn } from './lib/cn'
|
|
15
|
+
import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from './icon.constant'
|
|
16
|
+
import { useContainerWidth } from './hooks/useContainerWidth'
|
|
14
17
|
import { Avatar } from './Avatar'
|
|
15
18
|
import { ChannelIcon } from './ChannelIcon'
|
|
16
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Larguras do próprio cabeçalho (em px) que decidem o quanto ele recolhe. São da faixa, não da
|
|
22
|
+
* janela: com a prévia do simulador aberta a coluna fica com metade da tela, e pelo breakpoint da
|
|
23
|
+
* janela o cabeçalho continuava desenhando tudo — sobrava uma letra do nome do cliente.
|
|
24
|
+
*
|
|
25
|
+
* Abaixo da primeira, os utilitários (ícones de consulta) viram itens do menu ⋮ e o telefone volta a
|
|
26
|
+
* caber; abaixo da segunda, até as ações escritas entram no menu, porque nem elas cabem sem comer o
|
|
27
|
+
* nome. Quem identifica a conversa é o nome — ele é o último a ceder espaço.
|
|
28
|
+
*/
|
|
29
|
+
const HEADER_UTILITIES_MIN_WIDTH = 720
|
|
30
|
+
const HEADER_ACTIONS_MIN_WIDTH = 600
|
|
31
|
+
|
|
17
32
|
export interface ConversationHeaderLabels {
|
|
18
33
|
botMode: string
|
|
19
34
|
humanMode: string
|
|
@@ -27,8 +42,10 @@ export interface ConversationHeaderLabels {
|
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export const DEFAULT_CONVERSATION_HEADER_LABELS: ConversationHeaderLabels = {
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
// Sem emoji no rótulo: quem desenha o modo é o ícone ao lado, e o emoji embutido no texto voltava
|
|
46
|
+
// a aparecer no tooltip e no leitor de tela como um caractere sem nome.
|
|
47
|
+
botMode: 'atendimento automático',
|
|
48
|
+
humanMode: 'atendimento humano',
|
|
32
49
|
returnToBot: 'Devolver ao bot',
|
|
33
50
|
finish: 'Finalizar',
|
|
34
51
|
takeover: 'Assumir atendimento',
|
|
@@ -59,13 +76,26 @@ export interface ConversationHeaderClassNames {
|
|
|
59
76
|
*/
|
|
60
77
|
export interface ConversationHeaderUtility {
|
|
61
78
|
key: string
|
|
62
|
-
/**
|
|
63
|
-
icon:
|
|
79
|
+
/** Ícone da biblioteca (lucide), no tamanho dos utilitários nativos: `<Play size={16} />`. */
|
|
80
|
+
icon: ReactNode
|
|
64
81
|
label: string
|
|
65
82
|
run: () => void
|
|
66
83
|
active?: boolean
|
|
67
84
|
}
|
|
68
85
|
|
|
86
|
+
/** Utilitário já resolvido para desenho: o `active` opcional do contrato vira estado explícito. */
|
|
87
|
+
type ResolvedUtility = ConversationHeaderUtility & { active: boolean }
|
|
88
|
+
|
|
89
|
+
/** Ação de atendimento pronta para virar botão na faixa ou item do menu ⋮. */
|
|
90
|
+
type HeaderAction = {
|
|
91
|
+
key: string
|
|
92
|
+
icon: ReactNode
|
|
93
|
+
label: string
|
|
94
|
+
hint: string
|
|
95
|
+
run: () => void
|
|
96
|
+
className: string
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
export interface ConversationHeaderProps {
|
|
70
100
|
conversation: ConversationSummary
|
|
71
101
|
busy?: boolean
|
|
@@ -105,42 +135,92 @@ export function ConversationHeader({
|
|
|
105
135
|
const flag = contactFlag({ handle, channel: conversation.channel })
|
|
106
136
|
const capabilities = capabilitiesOf(conversation.channel)
|
|
107
137
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
138
|
+
const headerRef = useRef<HTMLElement>(null)
|
|
139
|
+
const headerWidth = useContainerWidth(headerRef)
|
|
140
|
+
|
|
141
|
+
// Sem medida ainda (SSR, primeiro render) o cabeçalho nasce completo: é o layout correto na maior
|
|
142
|
+
// parte dos casos, e recolher depois não tira nada do lugar de quem já estava lendo.
|
|
143
|
+
const showsUtilitiesInline = headerWidth === undefined || headerWidth >= HEADER_UTILITIES_MIN_WIDTH
|
|
144
|
+
const showsActionsInline = headerWidth === undefined || headerWidth >= HEADER_ACTIONS_MIN_WIDTH
|
|
108
145
|
|
|
109
146
|
// Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
|
|
110
147
|
// caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
|
|
111
|
-
const
|
|
148
|
+
const utilityCandidates: readonly (ResolvedUtility | undefined)[] = [
|
|
112
149
|
onOpenDocuments
|
|
113
|
-
? {
|
|
150
|
+
? {
|
|
151
|
+
key: 'documents',
|
|
152
|
+
icon: <FileText size={ICON_SIZE_ACTION} />,
|
|
153
|
+
label: labels.documents,
|
|
154
|
+
run: onOpenDocuments,
|
|
155
|
+
active: documentsOpen,
|
|
156
|
+
}
|
|
157
|
+
: undefined,
|
|
158
|
+
onDownload
|
|
159
|
+
? {
|
|
160
|
+
key: 'download',
|
|
161
|
+
icon: <Download size={ICON_SIZE_ACTION} />,
|
|
162
|
+
label: labels.download,
|
|
163
|
+
run: onDownload,
|
|
164
|
+
active: false,
|
|
165
|
+
}
|
|
114
166
|
: undefined,
|
|
115
|
-
onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
|
|
116
167
|
...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
|
|
117
|
-
]
|
|
118
|
-
|
|
119
|
-
Boolean(utility),
|
|
120
|
-
)
|
|
168
|
+
]
|
|
169
|
+
const utilities = utilityCandidates.filter((utility): utility is ResolvedUtility => Boolean(utility))
|
|
121
170
|
|
|
122
171
|
// Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
|
|
123
172
|
// superfícies nunca divergirem sobre o que está disponível.
|
|
124
|
-
|
|
173
|
+
// `hint` existe separado do `label` porque o host pode traduzir o rótulo com prefixo ou contagem:
|
|
174
|
+
// no tooltip isso vira ruído, e é o texto limpo que descreve a ação.
|
|
175
|
+
const actionCandidates: readonly (HeaderAction | undefined)[] = [
|
|
125
176
|
!isHuman && onTakeover
|
|
126
|
-
? {
|
|
177
|
+
? {
|
|
178
|
+
key: 'takeover',
|
|
179
|
+
icon: <Headset size={ICON_SIZE_ACTION} />,
|
|
180
|
+
label: labels.takeover,
|
|
181
|
+
hint: labels.takeover,
|
|
182
|
+
run: onTakeover,
|
|
183
|
+
className: 'cv-header-action--primary',
|
|
184
|
+
}
|
|
185
|
+
: undefined,
|
|
186
|
+
isHuman && onReturnToBot
|
|
187
|
+
? {
|
|
188
|
+
key: 'release',
|
|
189
|
+
icon: <Bot size={ICON_SIZE_ACTION} />,
|
|
190
|
+
label: labels.returnToBot,
|
|
191
|
+
hint: labels.returnToBot,
|
|
192
|
+
run: onReturnToBot,
|
|
193
|
+
className: '',
|
|
194
|
+
}
|
|
127
195
|
: undefined,
|
|
128
|
-
isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
|
|
129
196
|
isHuman && onFinish
|
|
130
|
-
? {
|
|
197
|
+
? {
|
|
198
|
+
key: 'finish',
|
|
199
|
+
icon: <X size={ICON_SIZE_ACTION} />,
|
|
200
|
+
label: labels.finish,
|
|
201
|
+
hint: labels.finish,
|
|
202
|
+
run: onFinish,
|
|
203
|
+
className: 'cv-header-action--danger',
|
|
204
|
+
}
|
|
131
205
|
: undefined,
|
|
132
|
-
]
|
|
206
|
+
]
|
|
207
|
+
const actions = actionCandidates.filter((action): action is HeaderAction => Boolean(action))
|
|
133
208
|
|
|
134
|
-
//
|
|
135
|
-
//
|
|
209
|
+
// O menu recebe só o que não coube na faixa: utilitários primeiro, porque são consulta e não
|
|
210
|
+
// alteram estado da conversa. Nada aparece nos dois lugares — item duplicado é o operador achando
|
|
211
|
+
// que são duas coisas diferentes.
|
|
136
212
|
const menuItems = [
|
|
137
|
-
...
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
213
|
+
...(showsUtilitiesInline
|
|
214
|
+
? []
|
|
215
|
+
: utilities.map((utility) => ({
|
|
216
|
+
key: utility.key,
|
|
217
|
+
icon: utility.icon,
|
|
218
|
+
label: utility.label,
|
|
219
|
+
hint: utility.label,
|
|
220
|
+
run: utility.run,
|
|
221
|
+
className: '',
|
|
222
|
+
}))),
|
|
223
|
+
...(showsActionsInline ? [] : actions),
|
|
144
224
|
]
|
|
145
225
|
|
|
146
226
|
// `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
|
|
@@ -148,6 +228,7 @@ export function ConversationHeader({
|
|
|
148
228
|
// fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
|
|
149
229
|
return (
|
|
150
230
|
<header
|
|
231
|
+
ref={headerRef}
|
|
151
232
|
className={cn(
|
|
152
233
|
'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
|
|
153
234
|
classNames?.root,
|
|
@@ -158,76 +239,93 @@ export function ConversationHeader({
|
|
|
158
239
|
{/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
|
|
159
240
|
uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
|
|
160
241
|
{onBack ? (
|
|
161
|
-
<button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back}
|
|
162
|
-
|
|
242
|
+
<button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} data-cv-tooltip={labels.back}>
|
|
243
|
+
<ArrowLeft size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
163
244
|
</button>
|
|
164
245
|
) : null}
|
|
165
246
|
{/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
|
|
166
247
|
<Avatar name={conversation.clientName} size="md" />
|
|
167
248
|
<div className="min-w-0">
|
|
168
249
|
<p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
|
|
169
|
-
|
|
250
|
+
{/* Flex com `items-center`, não texto corrido: ícone de canal e emoji de modo têm altura
|
|
251
|
+
maior que a fonte, e como elementos inline cada um assentava na própria baseline —
|
|
252
|
+
telefone, canal e modo saíam em três alturas diferentes. O gap substitui as margens. */}
|
|
253
|
+
<p className={cn('flex min-w-0 flex-nowrap items-center gap-2 overflow-hidden text-xs text-gray-500', classNames?.meta)}>
|
|
170
254
|
{/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
|
|
171
255
|
o que rotulava qualquer contato como brasileiro. */}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
256
|
+
<span className="truncate">
|
|
257
|
+
{flag ? `${flag} ` : ''}
|
|
258
|
+
{displayHandle}
|
|
259
|
+
</span>
|
|
260
|
+
{/* Em faixa estreita sobra só o ícone do canal e o do modo: o rótulo escrito empurra o
|
|
175
261
|
telefone para fora e não diz nada que o ícone já não diga. */}
|
|
176
|
-
<span className="
|
|
262
|
+
<span className="inline-flex flex-shrink-0 items-center gap-1">
|
|
177
263
|
<ChannelIcon channel={conversation.channel} />
|
|
178
|
-
<span
|
|
264
|
+
{showsUtilitiesInline ? <span>{capabilities.label}</span> : null}
|
|
179
265
|
</span>
|
|
180
|
-
<span
|
|
181
|
-
|
|
182
|
-
|
|
266
|
+
<span
|
|
267
|
+
className="inline-flex flex-shrink-0 items-center gap-1"
|
|
268
|
+
// Sem o rótulo escrito, o ícone sozinho não diria nada ao leitor de tela.
|
|
269
|
+
{...(showsUtilitiesInline
|
|
270
|
+
? {}
|
|
271
|
+
: { role: 'img', 'aria-label': isHuman ? labels.humanMode : labels.botMode, 'data-cv-tooltip': isHuman ? labels.humanMode : labels.botMode })}
|
|
272
|
+
>
|
|
273
|
+
{isHuman ? (
|
|
274
|
+
<UserRound size={ICON_SIZE_INLINE} aria-hidden="true" />
|
|
275
|
+
) : (
|
|
276
|
+
<Bot size={ICON_SIZE_INLINE} aria-hidden="true" />
|
|
277
|
+
)}
|
|
278
|
+
{showsUtilitiesInline ? <span>{isHuman ? labels.humanMode : labels.botMode}</span> : null}
|
|
183
279
|
</span>
|
|
184
280
|
</p>
|
|
185
281
|
</div>
|
|
186
282
|
</div>
|
|
187
283
|
|
|
188
284
|
<div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
|
|
189
|
-
{/*
|
|
190
|
-
<div className={cn('
|
|
191
|
-
{utilities.map((utility) => (
|
|
285
|
+
{/* Faixa larga: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
|
|
286
|
+
<div className={cn('flex items-center gap-2', classNames?.desktopActions)}>
|
|
287
|
+
{(showsUtilitiesInline ? utilities : []).map((utility) => (
|
|
192
288
|
<button
|
|
193
289
|
key={utility.key}
|
|
194
290
|
type="button"
|
|
195
291
|
onClick={utility.run}
|
|
196
292
|
disabled={busy}
|
|
197
293
|
aria-pressed={utility.active}
|
|
198
|
-
|
|
294
|
+
data-cv-tooltip={utility.label}
|
|
199
295
|
aria-label={utility.label}
|
|
200
296
|
className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
|
|
201
297
|
>
|
|
202
298
|
{utility.icon}
|
|
203
299
|
</button>
|
|
204
300
|
))}
|
|
205
|
-
{actions.map((action) => (
|
|
301
|
+
{(showsActionsInline ? actions : []).map((action) => (
|
|
206
302
|
<button
|
|
207
303
|
key={action.key}
|
|
208
304
|
type="button"
|
|
209
305
|
onClick={action.run}
|
|
210
306
|
disabled={busy}
|
|
307
|
+
data-cv-tooltip={action.hint} aria-label={action.hint}
|
|
211
308
|
className={`cv-header-action ${action.className}`}
|
|
212
309
|
>
|
|
310
|
+
{action.icon}
|
|
213
311
|
{action.label}
|
|
214
312
|
</button>
|
|
215
313
|
))}
|
|
216
314
|
</div>
|
|
217
315
|
|
|
218
|
-
{/*
|
|
219
|
-
de 44px de área de toque e disputavam espaço com o nome do cliente. */}
|
|
316
|
+
{/* Faixa estreita: um único ⋮ com o que não coube. Ícones de 30px lado a lado violavam o
|
|
317
|
+
mínimo de 44px de área de toque e disputavam espaço com o nome do cliente. */}
|
|
220
318
|
{menuItems.length > 0 ? (
|
|
221
|
-
<div className={cn('cv-menu
|
|
319
|
+
<div className={cn('cv-menu', classNames?.mobileMenu)}>
|
|
222
320
|
<button
|
|
223
321
|
type="button"
|
|
224
322
|
onClick={() => setMenuOpen(!menuOpen)}
|
|
225
323
|
aria-expanded={menuOpen}
|
|
226
324
|
aria-label={labels.moreActions}
|
|
227
|
-
|
|
325
|
+
data-cv-tooltip={labels.moreActions}
|
|
228
326
|
className="cv-header-icon cv-touch"
|
|
229
327
|
>
|
|
230
|
-
|
|
328
|
+
<MoreVertical size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
231
329
|
</button>
|
|
232
330
|
|
|
233
331
|
{menuOpen ? (
|
|
@@ -242,8 +340,10 @@ export function ConversationHeader({
|
|
|
242
340
|
item.run()
|
|
243
341
|
}}
|
|
244
342
|
disabled={busy}
|
|
343
|
+
data-cv-tooltip={item.hint} aria-label={item.hint}
|
|
245
344
|
className={`cv-header-action cv-touch ${item.className}`}
|
|
246
345
|
>
|
|
346
|
+
{item.icon}
|
|
247
347
|
{item.label}
|
|
248
348
|
</button>
|
|
249
349
|
))}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
|
+
import { CHAT_TEXT_PRIMARY_CLASS, CHAT_TEXT_SECONDARY_CLASS } from './theme'
|
|
2
3
|
import { Avatar } from './Avatar'
|
|
3
4
|
import { contactFlag, formatContactHandle } from './conversationChannel'
|
|
4
5
|
import type { ConversationSummary } from './providers/types'
|
|
@@ -128,6 +129,7 @@ export const ConversationListItem = ({
|
|
|
128
129
|
|
|
129
130
|
return (
|
|
130
131
|
<button
|
|
132
|
+
data-cv-tooltip={name} aria-label={name}
|
|
131
133
|
onClick={handleClick}
|
|
132
134
|
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${highlightActive ? "hover:bg-[#f0f2f5]" : ""} ${showDivider ? "border-b border-[#e9edef]" : ""}`}
|
|
133
135
|
style={{
|
|
@@ -148,29 +150,29 @@ export const ConversationListItem = ({
|
|
|
148
150
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
149
151
|
{/* Sem nome, o título já é o telefone — a bandeira vai nele. */}
|
|
150
152
|
{!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
|
|
151
|
-
<span className=
|
|
153
|
+
<span className={`text-[16px] ${CHAT_TEXT_PRIMARY_CLASS} truncate`}>{name}</span>
|
|
152
154
|
{windowStatus && windowStatus.label === 'expired' && (
|
|
153
|
-
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0"
|
|
155
|
+
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" data-cv-tooltip={expiredWindowLabel} />
|
|
154
156
|
)}
|
|
155
157
|
{windowStatus && windowStatus.label === 'warning' && (
|
|
156
|
-
<span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
|
|
158
|
+
<span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" data-cv-tooltip={warningWindowLabel} />
|
|
157
159
|
)}
|
|
158
160
|
</div>
|
|
159
161
|
{timestamp && (
|
|
160
|
-
<span className=
|
|
162
|
+
<span className={`text-xs ${CHAT_TEXT_SECONDARY_CLASS} flex-shrink-0`}>{timestamp}</span>
|
|
161
163
|
)}
|
|
162
164
|
</div>
|
|
163
165
|
{/* Telefone só como subtítulo quando o título é o nome — repetir o número embaixo dele
|
|
164
166
|
mesmo gastaria uma linha para dizer duas vezes a mesma coisa. */}
|
|
165
167
|
{conversation.clientName ? (
|
|
166
|
-
<div className=
|
|
168
|
+
<div className={`flex items-center gap-1 text-xs ${CHAT_TEXT_SECONDARY_CLASS}`}>
|
|
167
169
|
{flag ? <span aria-hidden>{flag}</span> : null}
|
|
168
170
|
<span className="truncate">{displayHandle}</span>
|
|
169
171
|
</div>
|
|
170
172
|
) : null}
|
|
171
173
|
|
|
172
174
|
<div className="flex items-center justify-between mt-0.5">
|
|
173
|
-
<span className=
|
|
175
|
+
<span className={`text-sm ${CHAT_TEXT_SECONDARY_CLASS} truncate max-w-[180px]`}>
|
|
174
176
|
{preview.icon && <span className="mr-1">{preview.icon}</span>}
|
|
175
177
|
{preview.preview || 'Sem mensagens'}
|
|
176
178
|
</span>
|
|
@@ -22,6 +22,20 @@ export interface ConversationLocales {
|
|
|
22
22
|
untitledDocument: string
|
|
23
23
|
downloadFile: string
|
|
24
24
|
}
|
|
25
|
+
transcription: {
|
|
26
|
+
label: string
|
|
27
|
+
copy: string
|
|
28
|
+
copied: string
|
|
29
|
+
transcribe: string
|
|
30
|
+
transcribing: string
|
|
31
|
+
retry: string
|
|
32
|
+
failed: string
|
|
33
|
+
/** Áudio processado sem fala detectada — distinto de "não transcrito". */
|
|
34
|
+
empty: string
|
|
35
|
+
unsupported: string
|
|
36
|
+
showMore: string
|
|
37
|
+
showLess: string
|
|
38
|
+
}
|
|
25
39
|
selection: {
|
|
26
40
|
select: string
|
|
27
41
|
}
|
|
@@ -53,6 +67,19 @@ const DEFAULT_LOCALES: ConversationLocales = {
|
|
|
53
67
|
untitledDocument: 'Documento',
|
|
54
68
|
downloadFile: 'Baixar',
|
|
55
69
|
},
|
|
70
|
+
transcription: {
|
|
71
|
+
label: 'Transcrição',
|
|
72
|
+
copy: 'Copiar',
|
|
73
|
+
copied: 'Copiado!',
|
|
74
|
+
transcribe: 'Transcrever áudio',
|
|
75
|
+
transcribing: 'Transcrevendo...',
|
|
76
|
+
retry: 'Transcrever novamente',
|
|
77
|
+
failed: 'Falha ao transcrever — tentar novamente',
|
|
78
|
+
empty: 'Sem fala detectada',
|
|
79
|
+
unsupported: 'Formato de áudio não suportado para transcrição',
|
|
80
|
+
showMore: 'ver transcrição completa',
|
|
81
|
+
showLess: 'ver menos',
|
|
82
|
+
},
|
|
56
83
|
selection: {
|
|
57
84
|
select: 'Selecionar',
|
|
58
85
|
},
|
|
@@ -76,6 +103,7 @@ export interface ConversationLocalesProviderProps {
|
|
|
76
103
|
export function ConversationLocalesProvider({ children, locales }: ConversationLocalesProviderProps) {
|
|
77
104
|
const merged: ConversationLocales = {
|
|
78
105
|
bubble: { ...DEFAULT_LOCALES.bubble, ...locales?.bubble },
|
|
106
|
+
transcription: { ...DEFAULT_LOCALES.transcription, ...locales?.transcription },
|
|
79
107
|
selection: { ...DEFAULT_LOCALES.selection, ...locales?.selection },
|
|
80
108
|
dateDivider: { ...DEFAULT_LOCALES.dateDivider, ...locales?.dateDivider },
|
|
81
109
|
}
|
package/src/ConversationRow.tsx
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* como se opera uma fila de atendimento no WhatsApp.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { AlarmClock, Bot, Hourglass, Play, UserRound } from 'lucide-react'
|
|
11
|
+
|
|
10
12
|
import { ConversationListItem } from './ConversationListItem'
|
|
11
13
|
import { capabilitiesOf } from './conversationChannel'
|
|
14
|
+
import { ICON_SIZE_ACTION, ICON_SIZE_PILL } from './icon.constant'
|
|
12
15
|
import { cn } from './lib/cn'
|
|
13
16
|
import { ChannelIcon } from './ChannelIcon'
|
|
14
17
|
import type { ConversationSummary } from './providers/types'
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
windowOf,
|
|
19
22
|
type ConversationWindow,
|
|
20
23
|
} from './conversationWindow'
|
|
24
|
+
import { REPLY_LATENCY, replyLatencyOf } from './replyLatency'
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
@@ -31,6 +35,16 @@ const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
|
31
35
|
// Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
|
|
32
36
|
const STALLED_THRESHOLD_MS = 60 * 60 * 1000
|
|
33
37
|
|
|
38
|
+
// Verde só até 6h: a partir daí o selo perde a cor de "tudo certo", que é o sinal que o operador
|
|
39
|
+
// varre na lista. Crítico ganha vermelho para não se confundir com uma espera de 7h.
|
|
40
|
+
const REPLY_LATENCY_PILL_CLASS = {
|
|
41
|
+
[REPLY_LATENCY.WITHIN]: 'cv-pill--success',
|
|
42
|
+
[REPLY_LATENCY.LATE]: 'cv-pill--warning',
|
|
43
|
+
[REPLY_LATENCY.CRITICAL]: 'cv-pill--danger',
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
const TAKEOVER_LABEL = 'Continuar Atendimento'
|
|
47
|
+
|
|
34
48
|
const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
|
|
35
49
|
[CONVERSATION_WINDOW.ALL]: 'bg-transparent',
|
|
36
50
|
[CONVERSATION_WINDOW.FRESH]: 'bg-green-500',
|
|
@@ -76,6 +90,13 @@ export function ConversationRow({
|
|
|
76
90
|
// ao `waitingHuman` escondia justamente o caso ruim: conversa assumida e esquecida.
|
|
77
91
|
const isStalled = stalledMs > STALLED_THRESHOLD_MS
|
|
78
92
|
const isWaiting = conversation.mode === 'bot' && conversation.waitingHuman
|
|
93
|
+
// Espera do cliente por resposta — outra coisa que a janela de sessão acima: aqui o relógio para
|
|
94
|
+
// quando alguém responde. Ver `replyLatency.ts`.
|
|
95
|
+
const replyLatency = replyLatencyOf({
|
|
96
|
+
lastDirection: conversation.lastDirection,
|
|
97
|
+
lastInboundAt: conversation.lastInboundAt,
|
|
98
|
+
now,
|
|
99
|
+
})
|
|
79
100
|
|
|
80
101
|
// Realce e hover ficam na linha inteira: com eles no item, só o bloco dele ficava cinza enquanto
|
|
81
102
|
// checkbox, barra lateral e pills continuavam brancos — parecia meia linha selecionada.
|
|
@@ -85,7 +106,7 @@ export function ConversationRow({
|
|
|
85
106
|
duplica as bolinhas que o próprio ConversationListItem já desenha. */}
|
|
86
107
|
<span
|
|
87
108
|
className={cn('w-1 shrink-0', WINDOW_BAR_CLASS[window], classNames?.windowBar)}
|
|
88
|
-
|
|
109
|
+
data-cv-tooltip={WINDOW_TITLE[window]}
|
|
89
110
|
aria-label={WINDOW_TITLE[window]}
|
|
90
111
|
/>
|
|
91
112
|
|
|
@@ -118,16 +139,41 @@ export function ConversationRow({
|
|
|
118
139
|
<ChannelIcon channel={conversation.channel} /> {capabilities.label}
|
|
119
140
|
</span>
|
|
120
141
|
{conversation.mode === 'human' ? (
|
|
121
|
-
<span className="cv-pill cv-pill--success"
|
|
142
|
+
<span className="cv-pill cv-pill--success">
|
|
143
|
+
<UserRound size={ICON_SIZE_PILL} aria-hidden="true" /> em atendimento
|
|
144
|
+
</span>
|
|
145
|
+
) : null}
|
|
146
|
+
{isWaiting ? (
|
|
147
|
+
<span className="cv-pill cv-pill--warning">
|
|
148
|
+
<Hourglass size={ICON_SIZE_PILL} aria-hidden="true" /> aguardando atendimento
|
|
149
|
+
</span>
|
|
150
|
+
) : null}
|
|
151
|
+
{!isWaiting && conversation.mode === 'bot' ? (
|
|
152
|
+
<span className="cv-pill">
|
|
153
|
+
<Bot size={ICON_SIZE_PILL} aria-hidden="true" /> bot ativo
|
|
154
|
+
</span>
|
|
155
|
+
) : null}
|
|
156
|
+
{replyLatency ? (
|
|
157
|
+
<span className={cn('cv-pill', REPLY_LATENCY_PILL_CLASS[replyLatency])}>
|
|
158
|
+
<AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> sem resposta há{' '}
|
|
159
|
+
{formatStalledFor(conversation.lastInboundAt ?? conversation.lastAt, now)}
|
|
160
|
+
</span>
|
|
122
161
|
) : null}
|
|
123
|
-
{isWaiting ? <span className="cv-pill cv-pill--warning">⏳ aguardando atendimento</span> : null}
|
|
124
|
-
{!isWaiting && conversation.mode === 'bot' ? <span className="cv-pill">🤖 bot ativo</span> : null}
|
|
125
162
|
{isStalled ? (
|
|
126
|
-
<span className="cv-pill cv-pill--danger"
|
|
163
|
+
<span className="cv-pill cv-pill--danger">
|
|
164
|
+
<AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> parada há{' '}
|
|
165
|
+
{formatStalledFor(conversation.lastAt, now)}
|
|
166
|
+
</span>
|
|
127
167
|
) : null}
|
|
128
168
|
{isWaiting ? (
|
|
129
|
-
<button
|
|
130
|
-
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={onTakeover}
|
|
172
|
+
disabled={busy}
|
|
173
|
+
data-cv-tooltip={TAKEOVER_LABEL} aria-label={TAKEOVER_LABEL}
|
|
174
|
+
className="cv-header-action"
|
|
175
|
+
>
|
|
176
|
+
<Play size={ICON_SIZE_ACTION} aria-hidden="true" /> {TAKEOVER_LABEL}
|
|
131
177
|
</button>
|
|
132
178
|
) : null}
|
|
133
179
|
</div>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { DarkModeToggle } from './DarkModeToggle'
|
|
5
|
+
|
|
6
|
+
const noop = () => undefined
|
|
7
|
+
|
|
8
|
+
describe('DarkModeToggle', () => {
|
|
9
|
+
it('nomeia o destino do clique, não o tema atual', () => {
|
|
10
|
+
const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} />)
|
|
11
|
+
|
|
12
|
+
expect(markup).toContain('aria-label="Tema escuro"')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('inverte o rótulo quando o tema escuro está no ar', () => {
|
|
16
|
+
const markup = renderToStaticMarkup(<DarkModeToggle isDark onToggle={noop} />)
|
|
17
|
+
|
|
18
|
+
expect(markup).toContain('aria-label="Tema claro"')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('aceita rótulo do host', () => {
|
|
22
|
+
const markup = renderToStaticMarkup(
|
|
23
|
+
<DarkModeToggle isDark={false} onToggle={noop} labels={{ toDark: 'Modo noturno' }} />,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect(markup).toContain('aria-label="Modo noturno"')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('só-ícone garante a área de toque mínima', () => {
|
|
30
|
+
const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} />)
|
|
31
|
+
|
|
32
|
+
expect(markup).toContain('cv-touch')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('com rótulo visível dispensa a área de toque forçada e o tooltip', () => {
|
|
36
|
+
const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} showLabel />)
|
|
37
|
+
|
|
38
|
+
expect(markup).not.toContain('cv-touch')
|
|
39
|
+
expect(markup).not.toContain('data-cv-tooltip')
|
|
40
|
+
expect(markup).toContain('Tema escuro')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('deixa o host trocar a aparência sem herdar o utilitário do pacote', () => {
|
|
44
|
+
const markup = renderToStaticMarkup(
|
|
45
|
+
<DarkModeToggle isDark={false} onToggle={noop} className="rounded-2xl px-4" />,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect(markup).toContain('rounded-2xl')
|
|
49
|
+
expect(markup).not.toContain('rounded-lg')
|
|
50
|
+
expect(markup).not.toContain('px-3')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** Trava o contorno da limitação documentada: token do host não é família conhecida do merge. */
|
|
54
|
+
it('resolve raio de token do host quando ele vem na forma arbitrária', () => {
|
|
55
|
+
const markup = renderToStaticMarkup(
|
|
56
|
+
<DarkModeToggle isDark={false} onToggle={noop} className="rounded-[var(--radius-panel)]" />,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(markup).not.toContain('rounded-lg')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('cede o tamanho do ícone quando o host manda classe', () => {
|
|
63
|
+
const markup = renderToStaticMarkup(
|
|
64
|
+
<DarkModeToggle isDark={false} onToggle={noop} classNames={{ icon: 'size-4' }} />,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(markup).toContain('size-4')
|
|
68
|
+
expect(markup).not.toContain('width="16"')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('marca o ícone como decorativo — o rótulo já é anunciado', () => {
|
|
72
|
+
const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} />)
|
|
73
|
+
|
|
74
|
+
expect(markup).toContain('aria-hidden="true"')
|
|
75
|
+
})
|
|
76
|
+
})
|