@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41
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-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2389 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- 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 +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- 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.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- 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/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -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 +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1219 -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/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/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- 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 +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -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 +225 -17
- 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/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- 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 +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -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 +4 -1
- 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/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- 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/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -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',
|
|
@@ -52,6 +69,33 @@ export interface ConversationHeaderClassNames {
|
|
|
52
69
|
mobileMenu: string
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Utilitário extra que o host pendura no cabeçalho — ícone no desktop, item de menu no celular,
|
|
74
|
+
* como os nativos. Entra por aqui, e não por um slot de ReactNode, porque é isso que preserva o
|
|
75
|
+
* comportamento responsivo: um nó solto viraria um quarto ícone em 375px, sem área de toque.
|
|
76
|
+
*/
|
|
77
|
+
export interface ConversationHeaderUtility {
|
|
78
|
+
key: string
|
|
79
|
+
/** Ícone da biblioteca (lucide), no tamanho dos utilitários nativos: `<Play size={16} />`. */
|
|
80
|
+
icon: ReactNode
|
|
81
|
+
label: string
|
|
82
|
+
run: () => void
|
|
83
|
+
active?: boolean
|
|
84
|
+
}
|
|
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
|
+
|
|
55
99
|
export interface ConversationHeaderProps {
|
|
56
100
|
conversation: ConversationSummary
|
|
57
101
|
busy?: boolean
|
|
@@ -61,6 +105,8 @@ export interface ConversationHeaderProps {
|
|
|
61
105
|
onDownload?: () => void
|
|
62
106
|
onOpenDocuments?: () => void
|
|
63
107
|
documentsOpen?: boolean
|
|
108
|
+
/** Ações do produto que não existem no contrato do pacote (ex.: ferramentas de dev). */
|
|
109
|
+
extraUtilities?: readonly ConversationHeaderUtility[]
|
|
64
110
|
onBack?: () => void
|
|
65
111
|
labels?: Partial<ConversationHeaderLabels>
|
|
66
112
|
className?: string
|
|
@@ -76,6 +122,7 @@ export function ConversationHeader({
|
|
|
76
122
|
onDownload,
|
|
77
123
|
onOpenDocuments,
|
|
78
124
|
documentsOpen = false,
|
|
125
|
+
extraUtilities,
|
|
79
126
|
onBack,
|
|
80
127
|
labels: labelsOverride,
|
|
81
128
|
className,
|
|
@@ -88,41 +135,92 @@ export function ConversationHeader({
|
|
|
88
135
|
const flag = contactFlag({ handle, channel: conversation.channel })
|
|
89
136
|
const capabilities = capabilitiesOf(conversation.channel)
|
|
90
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
|
|
91
145
|
|
|
92
146
|
// Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
|
|
93
147
|
// caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
|
|
94
|
-
const
|
|
148
|
+
const utilityCandidates: readonly (ResolvedUtility | undefined)[] = [
|
|
95
149
|
onOpenDocuments
|
|
96
|
-
? {
|
|
150
|
+
? {
|
|
151
|
+
key: 'documents',
|
|
152
|
+
icon: <FileText size={ICON_SIZE_ACTION} />,
|
|
153
|
+
label: labels.documents,
|
|
154
|
+
run: onOpenDocuments,
|
|
155
|
+
active: documentsOpen,
|
|
156
|
+
}
|
|
97
157
|
: undefined,
|
|
98
|
-
onDownload
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
158
|
+
onDownload
|
|
159
|
+
? {
|
|
160
|
+
key: 'download',
|
|
161
|
+
icon: <Download size={ICON_SIZE_ACTION} />,
|
|
162
|
+
label: labels.download,
|
|
163
|
+
run: onDownload,
|
|
164
|
+
active: false,
|
|
165
|
+
}
|
|
166
|
+
: undefined,
|
|
167
|
+
...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
|
|
168
|
+
]
|
|
169
|
+
const utilities = utilityCandidates.filter((utility): utility is ResolvedUtility => Boolean(utility))
|
|
103
170
|
|
|
104
171
|
// Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
|
|
105
172
|
// superfícies nunca divergirem sobre o que está disponível.
|
|
106
|
-
|
|
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)[] = [
|
|
107
176
|
!isHuman && onTakeover
|
|
108
|
-
? {
|
|
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
|
+
}
|
|
109
195
|
: undefined,
|
|
110
|
-
isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
|
|
111
196
|
isHuman && onFinish
|
|
112
|
-
? {
|
|
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
|
+
}
|
|
113
205
|
: undefined,
|
|
114
|
-
]
|
|
206
|
+
]
|
|
207
|
+
const actions = actionCandidates.filter((action): action is HeaderAction => Boolean(action))
|
|
115
208
|
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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.
|
|
118
212
|
const menuItems = [
|
|
119
|
-
...
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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),
|
|
126
224
|
]
|
|
127
225
|
|
|
128
226
|
// `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
|
|
@@ -130,6 +228,7 @@ export function ConversationHeader({
|
|
|
130
228
|
// fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
|
|
131
229
|
return (
|
|
132
230
|
<header
|
|
231
|
+
ref={headerRef}
|
|
133
232
|
className={cn(
|
|
134
233
|
'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
|
|
135
234
|
classNames?.root,
|
|
@@ -140,76 +239,93 @@ export function ConversationHeader({
|
|
|
140
239
|
{/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
|
|
141
240
|
uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
|
|
142
241
|
{onBack ? (
|
|
143
|
-
<button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back}
|
|
144
|
-
|
|
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" />
|
|
145
244
|
</button>
|
|
146
245
|
) : null}
|
|
147
246
|
{/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
|
|
148
247
|
<Avatar name={conversation.clientName} size="md" />
|
|
149
248
|
<div className="min-w-0">
|
|
150
249
|
<p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
|
|
151
|
-
|
|
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)}>
|
|
152
254
|
{/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
|
|
153
255
|
o que rotulava qualquer contato como brasileiro. */}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
157
261
|
telefone para fora e não diz nada que o ícone já não diga. */}
|
|
158
|
-
<span className="
|
|
262
|
+
<span className="inline-flex flex-shrink-0 items-center gap-1">
|
|
159
263
|
<ChannelIcon channel={conversation.channel} />
|
|
160
|
-
<span
|
|
264
|
+
{showsUtilitiesInline ? <span>{capabilities.label}</span> : null}
|
|
161
265
|
</span>
|
|
162
|
-
<span
|
|
163
|
-
|
|
164
|
-
|
|
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}
|
|
165
279
|
</span>
|
|
166
280
|
</p>
|
|
167
281
|
</div>
|
|
168
282
|
</div>
|
|
169
283
|
|
|
170
284
|
<div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
|
|
171
|
-
{/*
|
|
172
|
-
<div className={cn('
|
|
173
|
-
{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) => (
|
|
174
288
|
<button
|
|
175
289
|
key={utility.key}
|
|
176
290
|
type="button"
|
|
177
291
|
onClick={utility.run}
|
|
178
292
|
disabled={busy}
|
|
179
293
|
aria-pressed={utility.active}
|
|
180
|
-
|
|
294
|
+
data-cv-tooltip={utility.label}
|
|
181
295
|
aria-label={utility.label}
|
|
182
296
|
className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
|
|
183
297
|
>
|
|
184
298
|
{utility.icon}
|
|
185
299
|
</button>
|
|
186
300
|
))}
|
|
187
|
-
{actions.map((action) => (
|
|
301
|
+
{(showsActionsInline ? actions : []).map((action) => (
|
|
188
302
|
<button
|
|
189
303
|
key={action.key}
|
|
190
304
|
type="button"
|
|
191
305
|
onClick={action.run}
|
|
192
306
|
disabled={busy}
|
|
307
|
+
data-cv-tooltip={action.hint} aria-label={action.hint}
|
|
193
308
|
className={`cv-header-action ${action.className}`}
|
|
194
309
|
>
|
|
310
|
+
{action.icon}
|
|
195
311
|
{action.label}
|
|
196
312
|
</button>
|
|
197
313
|
))}
|
|
198
314
|
</div>
|
|
199
315
|
|
|
200
|
-
{/*
|
|
201
|
-
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. */}
|
|
202
318
|
{menuItems.length > 0 ? (
|
|
203
|
-
<div className={cn('cv-menu
|
|
319
|
+
<div className={cn('cv-menu', classNames?.mobileMenu)}>
|
|
204
320
|
<button
|
|
205
321
|
type="button"
|
|
206
322
|
onClick={() => setMenuOpen(!menuOpen)}
|
|
207
323
|
aria-expanded={menuOpen}
|
|
208
324
|
aria-label={labels.moreActions}
|
|
209
|
-
|
|
325
|
+
data-cv-tooltip={labels.moreActions}
|
|
210
326
|
className="cv-header-icon cv-touch"
|
|
211
327
|
>
|
|
212
|
-
|
|
328
|
+
<MoreVertical size={ICON_SIZE_ACTION} aria-hidden="true" />
|
|
213
329
|
</button>
|
|
214
330
|
|
|
215
331
|
{menuOpen ? (
|
|
@@ -224,8 +340,10 @@ export function ConversationHeader({
|
|
|
224
340
|
item.run()
|
|
225
341
|
}}
|
|
226
342
|
disabled={busy}
|
|
343
|
+
data-cv-tooltip={item.hint} aria-label={item.hint}
|
|
227
344
|
className={`cv-header-action cv-touch ${item.className}`}
|
|
228
345
|
>
|
|
346
|
+
{item.icon}
|
|
229
347
|
{item.label}
|
|
230
348
|
</button>
|
|
231
349
|
))}
|
|
@@ -3,8 +3,21 @@ import { Avatar } from './Avatar'
|
|
|
3
3
|
import { contactFlag, formatContactHandle } from './conversationChannel'
|
|
4
4
|
import type { ConversationSummary } from './providers/types'
|
|
5
5
|
|
|
6
|
+
export interface ConversationListItemLabels {
|
|
7
|
+
/** Tooltip do ponto vermelho: a janela de atendimento de 24h já fechou. */
|
|
8
|
+
expiredWindow: string
|
|
9
|
+
/** Tooltip do ponto laranja: a janela de atendimento está perto de fechar. */
|
|
10
|
+
warningWindow: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_CONVERSATION_LIST_ITEM_LABELS: ConversationListItemLabels = {
|
|
14
|
+
expiredWindow: 'Janela expirada',
|
|
15
|
+
warningWindow: 'Janela próxima do fim',
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export interface ConversationListItemProps {
|
|
7
19
|
conversation: ConversationSummary
|
|
20
|
+
labels?: Partial<ConversationListItemLabels>
|
|
8
21
|
active?: boolean
|
|
9
22
|
selected?: boolean
|
|
10
23
|
onClick?: () => void
|
|
@@ -92,7 +105,10 @@ export const ConversationListItem = ({
|
|
|
92
105
|
onSelect,
|
|
93
106
|
showDivider = true,
|
|
94
107
|
highlightActive = true,
|
|
108
|
+
labels,
|
|
95
109
|
}: ConversationListItemProps) => {
|
|
110
|
+
const expiredWindowLabel = labels?.expiredWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.expiredWindow
|
|
111
|
+
const warningWindowLabel = labels?.warningWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.warningWindow
|
|
96
112
|
const isActive = active || selected
|
|
97
113
|
const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
|
|
98
114
|
const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
|
|
@@ -112,6 +128,7 @@ export const ConversationListItem = ({
|
|
|
112
128
|
|
|
113
129
|
return (
|
|
114
130
|
<button
|
|
131
|
+
data-cv-tooltip={name} aria-label={name}
|
|
115
132
|
onClick={handleClick}
|
|
116
133
|
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]" : ""}`}
|
|
117
134
|
style={{
|
|
@@ -134,10 +151,10 @@ export const ConversationListItem = ({
|
|
|
134
151
|
{!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
|
|
135
152
|
<span className="text-[16px] text-[#111b21] truncate">{name}</span>
|
|
136
153
|
{windowStatus && windowStatus.label === 'expired' && (
|
|
137
|
-
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0"
|
|
154
|
+
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" data-cv-tooltip={expiredWindowLabel} />
|
|
138
155
|
)}
|
|
139
156
|
{windowStatus && windowStatus.label === 'warning' && (
|
|
140
|
-
<span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
|
|
157
|
+
<span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" data-cv-tooltip={warningWindowLabel} />
|
|
141
158
|
)}
|
|
142
159
|
</div>
|
|
143
160
|
{timestamp && (
|
|
@@ -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'
|
|
@@ -31,6 +34,8 @@ const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
|
31
34
|
// Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
|
|
32
35
|
const STALLED_THRESHOLD_MS = 60 * 60 * 1000
|
|
33
36
|
|
|
37
|
+
const TAKEOVER_LABEL = 'Continuar Atendimento'
|
|
38
|
+
|
|
34
39
|
const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
|
|
35
40
|
[CONVERSATION_WINDOW.ALL]: 'bg-transparent',
|
|
36
41
|
[CONVERSATION_WINDOW.FRESH]: 'bg-green-500',
|
|
@@ -85,7 +90,7 @@ export function ConversationRow({
|
|
|
85
90
|
duplica as bolinhas que o próprio ConversationListItem já desenha. */}
|
|
86
91
|
<span
|
|
87
92
|
className={cn('w-1 shrink-0', WINDOW_BAR_CLASS[window], classNames?.windowBar)}
|
|
88
|
-
|
|
93
|
+
data-cv-tooltip={WINDOW_TITLE[window]}
|
|
89
94
|
aria-label={WINDOW_TITLE[window]}
|
|
90
95
|
/>
|
|
91
96
|
|
|
@@ -118,16 +123,35 @@ export function ConversationRow({
|
|
|
118
123
|
<ChannelIcon channel={conversation.channel} /> {capabilities.label}
|
|
119
124
|
</span>
|
|
120
125
|
{conversation.mode === 'human' ? (
|
|
121
|
-
<span className="cv-pill cv-pill--success"
|
|
126
|
+
<span className="cv-pill cv-pill--success">
|
|
127
|
+
<UserRound size={ICON_SIZE_PILL} aria-hidden="true" /> em atendimento
|
|
128
|
+
</span>
|
|
129
|
+
) : null}
|
|
130
|
+
{isWaiting ? (
|
|
131
|
+
<span className="cv-pill cv-pill--warning">
|
|
132
|
+
<Hourglass size={ICON_SIZE_PILL} aria-hidden="true" /> aguardando atendimento
|
|
133
|
+
</span>
|
|
134
|
+
) : null}
|
|
135
|
+
{!isWaiting && conversation.mode === 'bot' ? (
|
|
136
|
+
<span className="cv-pill">
|
|
137
|
+
<Bot size={ICON_SIZE_PILL} aria-hidden="true" /> bot ativo
|
|
138
|
+
</span>
|
|
122
139
|
) : 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
140
|
{isStalled ? (
|
|
126
|
-
<span className="cv-pill cv-pill--danger"
|
|
141
|
+
<span className="cv-pill cv-pill--danger">
|
|
142
|
+
<AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> parada há{' '}
|
|
143
|
+
{formatStalledFor(conversation.lastAt, now)}
|
|
144
|
+
</span>
|
|
127
145
|
) : null}
|
|
128
146
|
{isWaiting ? (
|
|
129
|
-
<button
|
|
130
|
-
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={onTakeover}
|
|
150
|
+
disabled={busy}
|
|
151
|
+
data-cv-tooltip={TAKEOVER_LABEL} aria-label={TAKEOVER_LABEL}
|
|
152
|
+
className="cv-header-action"
|
|
153
|
+
>
|
|
154
|
+
<Play size={ICON_SIZE_ACTION} aria-hidden="true" /> {TAKEOVER_LABEL}
|
|
131
155
|
</button>
|
|
132
156
|
) : null}
|
|
133
157
|
</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
|
+
})
|