@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,252 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { Check, Copy, FileText, Loader2, RefreshCw } from 'lucide-react'
|
|
3
|
+
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
4
|
+
import { cn } from './lib/cn'
|
|
5
|
+
import type { MessageTranscription } from './types'
|
|
6
|
+
|
|
7
|
+
export interface AudioTranscriptionProps {
|
|
8
|
+
transcription?: MessageTranscription | null
|
|
9
|
+
/**
|
|
10
|
+
* Pede a transcrição ao backend. Ausente, o bloco nunca oferece o botão — um host em modo
|
|
11
|
+
* automático não tem rota para isso, e desenhar um botão que estoura no clique é pior do que não
|
|
12
|
+
* desenhar nada.
|
|
13
|
+
*
|
|
14
|
+
* O que for devolvido é exibido na hora. Sem isso o texto só apareceria no próximo refetch da
|
|
15
|
+
* lista, e transcrição não emite evento de tempo real: o operador clicaria, veria o spinner
|
|
16
|
+
* terminar e continuaria sem o texto na tela.
|
|
17
|
+
*/
|
|
18
|
+
onTranscribe?: () => Promise<MessageTranscription | void>
|
|
19
|
+
isMine?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Feedback de "copiado" some sozinho — confirmação que exige clique para fechar é ruído. */
|
|
23
|
+
const COPIED_FEEDBACK_MS = 2000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Acima disto a transcrição nasce recolhida.
|
|
27
|
+
*
|
|
28
|
+
* Medido: 1147 caracteres (cerca de um minuto de fala) produziram uma bolha de 854px — mais alta que
|
|
29
|
+
* a área visível da conversa, empurrando todo o resto para fora. Nota de voz de três minutos
|
|
30
|
+
* triplicaria. O limite é em caracteres, e não em linhas de CSS, porque a decisão precisa acontecer
|
|
31
|
+
* antes de renderizar: `line-clamp` sozinho esconde o texto mas ainda paga o layout inteiro.
|
|
32
|
+
*/
|
|
33
|
+
const COLLAPSE_ABOVE_CHARS = 320
|
|
34
|
+
|
|
35
|
+
/** Linhas visíveis quando recolhido — o bastante para saber do que o cliente está falando. */
|
|
36
|
+
const COLLAPSED_LINE_CLAMP = 4
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Transcrição sob a nota de voz, com botão de copiar.
|
|
40
|
+
*
|
|
41
|
+
* Copiar é o motivo de o bloco existir: o operador cola o pedido do cliente no sistema interno, no
|
|
42
|
+
* orçamento, na busca. Seleção manual de texto dentro de um balão de chat é exatamente onde o
|
|
43
|
+
* arrasto do mouse falha — pega o balão vizinho, o horário, o nome do remetente.
|
|
44
|
+
*
|
|
45
|
+
* `navigator.clipboard.writeText` com try/catch silencioso, mesmo padrão de `MessageText.tsx`: a API
|
|
46
|
+
* exige contexto seguro e permissão, e um host servindo em HTTP simples não a tem. Falhar sem alarme
|
|
47
|
+
* é o certo — o texto continua na tela para seleção manual.
|
|
48
|
+
*/
|
|
49
|
+
export function AudioTranscription({ transcription, onTranscribe, isMine = false }: AudioTranscriptionProps) {
|
|
50
|
+
const { transcription: locales } = useConversationLocales()
|
|
51
|
+
const [hasCopied, setHasCopied] = useState(false)
|
|
52
|
+
const [isTranscribing, setIsTranscribing] = useState(false)
|
|
53
|
+
const [hasRequestFailed, setHasRequestFailed] = useState(false)
|
|
54
|
+
/**
|
|
55
|
+
* Resultado que acabou de voltar do backend, exibido antes de a lista ser refeita.
|
|
56
|
+
*
|
|
57
|
+
* Tem precedência sobre a prop porque é o dado mais novo: o refetch, quando vier, traz a mesma
|
|
58
|
+
* coisa. Sem isto o clique terminaria em nada visível.
|
|
59
|
+
*/
|
|
60
|
+
const [justTranscribed, setJustTranscribed] = useState<MessageTranscription | undefined>()
|
|
61
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
62
|
+
|
|
63
|
+
const effective = justTranscribed ?? transcription
|
|
64
|
+
const text = effective?.text?.trim() ?? ''
|
|
65
|
+
const status = effective?.status
|
|
66
|
+
|
|
67
|
+
// Nasce recolhido e o operador decide: abrir tudo por padrão faria a nota de voz longa esconder as
|
|
68
|
+
// mensagens seguintes, que é justamente o contexto de que ele precisa para responder.
|
|
69
|
+
const isLong = text.length > COLLAPSE_ABOVE_CHARS
|
|
70
|
+
const isTruncated = isLong && !isExpanded
|
|
71
|
+
const isDone = status === 'done'
|
|
72
|
+
const hasText = isDone && text.length > 0
|
|
73
|
+
// Silêncio já processado: dizer "sem fala detectada" evita o operador clicar em transcrever de novo
|
|
74
|
+
// atrás de um texto que não existe.
|
|
75
|
+
const isSilent = isDone && text.length === 0
|
|
76
|
+
|
|
77
|
+
const handleCopy = useCallback(async () => {
|
|
78
|
+
if (!text) return
|
|
79
|
+
try {
|
|
80
|
+
await navigator.clipboard.writeText(text)
|
|
81
|
+
setHasCopied(true)
|
|
82
|
+
setTimeout(() => setHasCopied(false), COPIED_FEEDBACK_MS)
|
|
83
|
+
} catch {
|
|
84
|
+
// Clipboard indisponível (contexto não seguro, permissão negada) — o texto segue selecionável.
|
|
85
|
+
}
|
|
86
|
+
}, [text])
|
|
87
|
+
|
|
88
|
+
const handleTranscribe = useCallback(async () => {
|
|
89
|
+
if (!onTranscribe || isTranscribing) return
|
|
90
|
+
setIsTranscribing(true)
|
|
91
|
+
setHasRequestFailed(false)
|
|
92
|
+
try {
|
|
93
|
+
const result = await onTranscribe()
|
|
94
|
+
if (result) setJustTranscribed(result)
|
|
95
|
+
} catch {
|
|
96
|
+
setHasRequestFailed(true)
|
|
97
|
+
} finally {
|
|
98
|
+
setIsTranscribing(false)
|
|
99
|
+
}
|
|
100
|
+
}, [onTranscribe, isTranscribing])
|
|
101
|
+
|
|
102
|
+
const dividerClass = isMine ? 'border-black/10 dark:border-white/10' : 'border-black/10 dark:border-white/10'
|
|
103
|
+
|
|
104
|
+
// Nada avaliado e sem rota para pedir: o balão fica como era antes da transcrição existir.
|
|
105
|
+
if (!status && !onTranscribe) return null
|
|
106
|
+
|
|
107
|
+
if (!status || status === 'pending' || status === 'failed') {
|
|
108
|
+
return (
|
|
109
|
+
<div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
|
|
110
|
+
<TranscribeButton
|
|
111
|
+
label={resolveActionLabel({ status, hasRequestFailed, isTranscribing, locales })}
|
|
112
|
+
isBusy={isTranscribing}
|
|
113
|
+
onClick={handleTranscribe}
|
|
114
|
+
isDisabled={!onTranscribe}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (status === 'unsupported') {
|
|
121
|
+
return (
|
|
122
|
+
<div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
|
|
123
|
+
<p className="text-xs italic text-gray-500 dark:text-gray-400">{locales.unsupported}</p>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
|
|
130
|
+
<div className="mb-0.5 flex items-center gap-1.5">
|
|
131
|
+
<FileText size={11} className="flex-shrink-0 text-gray-500 dark:text-gray-400" aria-hidden />
|
|
132
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
133
|
+
{locales.label}
|
|
134
|
+
</span>
|
|
135
|
+
|
|
136
|
+
{hasText && (
|
|
137
|
+
<button
|
|
138
|
+
onClick={handleCopy}
|
|
139
|
+
data-cv-tooltip={locales.copy}
|
|
140
|
+
aria-label={hasCopied ? locales.copied : locales.copy}
|
|
141
|
+
className="ml-auto flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
|
142
|
+
>
|
|
143
|
+
{hasCopied ? (
|
|
144
|
+
<>
|
|
145
|
+
<Check size={11} aria-hidden />
|
|
146
|
+
<span>{locales.copied}</span>
|
|
147
|
+
</>
|
|
148
|
+
) : (
|
|
149
|
+
<>
|
|
150
|
+
<Copy size={11} aria-hidden />
|
|
151
|
+
<span>{locales.copy}</span>
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{isSilent ? (
|
|
159
|
+
<p className="text-xs italic text-gray-500 dark:text-gray-400">{locales.empty}</p>
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
{/* `select-all` para o clique único selecionar tudo: é o fallback quando o clipboard não
|
|
163
|
+
está disponível, e o caminho de quem prefere copiar com o teclado. */}
|
|
164
|
+
<p
|
|
165
|
+
className={cn(
|
|
166
|
+
'select-all whitespace-pre-wrap break-words text-[13px] leading-[18px] text-gray-700 dark:text-gray-200',
|
|
167
|
+
isTruncated && 'overflow-hidden',
|
|
168
|
+
)}
|
|
169
|
+
style={
|
|
170
|
+
isTruncated
|
|
171
|
+
? {
|
|
172
|
+
display: '-webkit-box',
|
|
173
|
+
WebkitBoxOrient: 'vertical',
|
|
174
|
+
WebkitLineClamp: COLLAPSED_LINE_CLAMP,
|
|
175
|
+
}
|
|
176
|
+
: undefined
|
|
177
|
+
}
|
|
178
|
+
>
|
|
179
|
+
{text}
|
|
180
|
+
</p>
|
|
181
|
+
{/* O botão de copiar leva o texto INTEIRO, recolhido ou não — recolher é sobre altura na
|
|
182
|
+
tela, não sobre o que o operador cola no sistema interno. */}
|
|
183
|
+
{isLong && (
|
|
184
|
+
<button
|
|
185
|
+
data-cv-tooltip={isExpanded ? locales.showLess : locales.showMore} aria-label={isExpanded ? locales.showLess : locales.showMore}
|
|
186
|
+
onClick={() => setIsExpanded((current) => !current)}
|
|
187
|
+
className="mt-1 text-[11px] font-medium text-gray-500 underline decoration-dotted hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
188
|
+
>
|
|
189
|
+
{isExpanded ? locales.showLess : locales.showMore}
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Retranscrever fica escondido atrás do hover do balão: é ação rara e paga cota de engine. */}
|
|
196
|
+
{onTranscribe && (
|
|
197
|
+
<button
|
|
198
|
+
data-cv-tooltip={isTranscribing ? locales.transcribing : locales.retry} aria-label={isTranscribing ? locales.transcribing : locales.retry}
|
|
199
|
+
onClick={handleTranscribe}
|
|
200
|
+
disabled={isTranscribing}
|
|
201
|
+
className="mt-1 flex items-center gap-1 text-[11px] text-gray-400 opacity-0 transition-opacity hover:text-gray-600 focus:opacity-100 group-hover:opacity-100 disabled:opacity-50 dark:text-gray-500 dark:hover:text-gray-300"
|
|
202
|
+
>
|
|
203
|
+
{isTranscribing ? <Loader2 size={10} className="animate-spin" aria-hidden /> : <RefreshCw size={10} aria-hidden />}
|
|
204
|
+
<span>{isTranscribing ? locales.transcribing : locales.retry}</span>
|
|
205
|
+
</button>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveActionLabel(params: {
|
|
212
|
+
status?: MessageTranscription['status']
|
|
213
|
+
hasRequestFailed: boolean
|
|
214
|
+
isTranscribing: boolean
|
|
215
|
+
locales: { transcribe: string; transcribing: string; retry: string; failed: string }
|
|
216
|
+
}): string {
|
|
217
|
+
if (params.isTranscribing) return params.locales.transcribing
|
|
218
|
+
if (params.hasRequestFailed) return params.locales.retry
|
|
219
|
+
// `pending` é cota estourada ou falha transitória do lado do servidor: já foi tentado e vai sair,
|
|
220
|
+
// mas o operador que não quer esperar a retomada pode forçar agora.
|
|
221
|
+
if (params.status === 'pending') return params.locales.transcribing
|
|
222
|
+
if (params.status === 'failed') return params.locales.failed
|
|
223
|
+
return params.locales.transcribe
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function TranscribeButton({
|
|
227
|
+
label,
|
|
228
|
+
isBusy,
|
|
229
|
+
isDisabled,
|
|
230
|
+
onClick,
|
|
231
|
+
}: {
|
|
232
|
+
label: string
|
|
233
|
+
isBusy: boolean
|
|
234
|
+
isDisabled: boolean
|
|
235
|
+
onClick: () => void
|
|
236
|
+
}) {
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
data-cv-tooltip={label} aria-label={label}
|
|
240
|
+
onClick={onClick}
|
|
241
|
+
disabled={isBusy || isDisabled}
|
|
242
|
+
className="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
|
243
|
+
>
|
|
244
|
+
{isBusy ? (
|
|
245
|
+
<Loader2 size={11} className="animate-spin" aria-hidden />
|
|
246
|
+
) : (
|
|
247
|
+
<FileText size={11} aria-hidden />
|
|
248
|
+
)}
|
|
249
|
+
<span>{label}</span>
|
|
250
|
+
</button>
|
|
251
|
+
)
|
|
252
|
+
}
|
package/src/Avatar.tsx
CHANGED
|
@@ -81,7 +81,7 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '', labels }:
|
|
|
81
81
|
return (
|
|
82
82
|
<div
|
|
83
83
|
className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
|
|
84
|
-
|
|
84
|
+
data-cv-tooltip={name}
|
|
85
85
|
>
|
|
86
86
|
{getInitials(name)}
|
|
87
87
|
</div>
|
|
@@ -1,31 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* "Suas Seleções": o que o bot já coletou na conversa. Para o atendente que assume no meio, é a
|
|
3
|
-
* diferença entre ler o transcript inteiro e ver o estado
|
|
3
|
+
* diferença entre ler o transcript inteiro e ver o estado de relance.
|
|
4
4
|
*
|
|
5
5
|
* O pacote não interpreta o contexto — ele é `Record<string, unknown>` e cada produto nomeia as
|
|
6
6
|
* próprias chaves. O host traduz para `entries`; aqui só se decide como mostrar.
|
|
7
|
+
*
|
|
8
|
+
* Desenho em paridade com financiamento-imobiliario-bot/apps/web/src/components/SelectionsSummary.tsx:
|
|
9
|
+
* card com gradiente, badge de contagem, pills de status no cabeçalho e grid de cards com borda
|
|
10
|
+
* colorida por estado. O que ficou diferente de lá, e por quê, está comentado no ponto.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { useState } from 'react'
|
|
14
|
+
import { Check, ChevronDown, ChevronUp, Clock, Pencil } from 'lucide-react'
|
|
10
15
|
|
|
11
16
|
import { cn } from './lib/cn'
|
|
12
17
|
import { useIsNarrow } from './useIsNarrow'
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `completed` tem valor, `pending` ainda não foi coletado, `editing` é o cliente refazendo a
|
|
21
|
+
* resposta. O host só precisa mandar `status` para o terceiro caso — os dois primeiros saem do
|
|
22
|
+
* próprio `value`, e exigir o campo quebraria quem já usa o painel.
|
|
23
|
+
*/
|
|
24
|
+
export type ConversationContextStatus = 'completed' | 'pending' | 'editing'
|
|
25
|
+
|
|
14
26
|
export interface ConversationContextEntry {
|
|
15
27
|
key: string
|
|
16
28
|
label: string
|
|
17
29
|
value?: string | undefined
|
|
18
30
|
icon?: string
|
|
31
|
+
status?: ConversationContextStatus
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
export interface ConversationContextPanelLabels {
|
|
22
35
|
title: string
|
|
23
36
|
empty: string
|
|
37
|
+
collapse: string
|
|
38
|
+
expand: string
|
|
39
|
+
/** Placeholder do card ainda não coletado. */
|
|
40
|
+
notCollected: string
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
|
|
27
|
-
title: '
|
|
44
|
+
title: 'Suas Seleções',
|
|
28
45
|
empty: 'Nada coletado ainda nesta conversa.',
|
|
46
|
+
collapse: 'fechar',
|
|
47
|
+
expand: 'abrir',
|
|
48
|
+
notCollected: 'a coletar',
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
export interface ConversationContextPanelClassNames {
|
|
@@ -37,70 +57,224 @@ export interface ConversationContextPanelClassNames {
|
|
|
37
57
|
|
|
38
58
|
export interface ConversationContextPanelProps {
|
|
39
59
|
entries: readonly ConversationContextEntry[]
|
|
60
|
+
/**
|
|
61
|
+
* Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
|
|
62
|
+
*
|
|
63
|
+
* Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
|
|
64
|
+
* painel ocupa altura mostrando quase só pendências, e empurra a conversa — que é o que se veio ver.
|
|
65
|
+
*/
|
|
66
|
+
defaultOpen?: boolean
|
|
67
|
+
/**
|
|
68
|
+
* Rótulo do fluxo em curso, exibido em azul ao lado do título — no financiamento é o produto
|
|
69
|
+
* escolhido ("Consórcio", "MCMV"). Ausente, o cabeçalho fica só com título e contagens.
|
|
70
|
+
*/
|
|
71
|
+
flowLabel?: string
|
|
40
72
|
labels?: Partial<ConversationContextPanelLabels>
|
|
41
73
|
className?: string
|
|
42
74
|
classNames?: Partial<ConversationContextPanelClassNames>
|
|
43
75
|
}
|
|
44
76
|
|
|
77
|
+
function statusOf(entry: ConversationContextEntry): ConversationContextStatus {
|
|
78
|
+
if (entry.status) return entry.status
|
|
79
|
+
return entry.value ? 'completed' : 'pending'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Borda e fundo do card por estado. Pendente é tracejado — ver o comentário no grid. */
|
|
83
|
+
const CARD_STYLE: Record<ConversationContextStatus, string> = {
|
|
84
|
+
completed: 'bg-white dark:bg-gray-800 border-2 border-green-200 dark:border-green-800 shadow-sm',
|
|
85
|
+
editing: 'bg-white dark:bg-gray-800 border-2 border-blue-200 dark:border-blue-800 shadow-sm',
|
|
86
|
+
pending: 'border-2 border-dashed border-gray-200 bg-white/40 dark:border-gray-700 dark:bg-gray-800/30',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const GLYPH_STYLE: Record<ConversationContextStatus, string> = {
|
|
90
|
+
completed: 'text-green-600 dark:text-green-400',
|
|
91
|
+
editing: 'text-blue-600 dark:text-blue-400',
|
|
92
|
+
pending: 'text-gray-400 dark:text-gray-500',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function StatusGlyph({ status }: { status: ConversationContextStatus }) {
|
|
96
|
+
const size = 11
|
|
97
|
+
if (status === 'completed') return <Check size={size} aria-hidden />
|
|
98
|
+
if (status === 'editing') return <Pencil size={size} aria-hidden />
|
|
99
|
+
return <Clock size={size} aria-hidden />
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function CountPill({
|
|
103
|
+
status,
|
|
104
|
+
count,
|
|
105
|
+
className,
|
|
106
|
+
}: {
|
|
107
|
+
status: ConversationContextStatus
|
|
108
|
+
count: number
|
|
109
|
+
className: string
|
|
110
|
+
}) {
|
|
111
|
+
if (count === 0) return null
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<span className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium', className)}>
|
|
115
|
+
<StatusGlyph status={status} />
|
|
116
|
+
{count}
|
|
117
|
+
</span>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
45
121
|
export function ConversationContextPanel({
|
|
46
122
|
entries,
|
|
123
|
+
defaultOpen,
|
|
124
|
+
flowLabel,
|
|
47
125
|
labels: labelsOverride,
|
|
48
126
|
className,
|
|
49
127
|
classNames,
|
|
50
128
|
}: ConversationContextPanelProps) {
|
|
51
129
|
const labels = { ...DEFAULT_CONVERSATION_CONTEXT_LABELS, ...labelsOverride }
|
|
52
|
-
const filled = entries.filter((entry) => Boolean(entry.value))
|
|
53
130
|
|
|
54
|
-
|
|
55
|
-
|
|
131
|
+
const counts = { completed: 0, pending: 0, editing: 0 }
|
|
132
|
+
for (const entry of entries) counts[statusOf(entry)] += 1
|
|
133
|
+
|
|
134
|
+
// Abre sozinho só quando há algo coletado: um painel de seis pendências ocupa a altura da conversa
|
|
135
|
+
// sem informar nada. As contagens continuam visíveis fechado, que é o dado útil de relance.
|
|
56
136
|
//
|
|
57
137
|
// `undefined` = usuário ainda não mexeu. Guardar só um booleano não serve: o contexto chega
|
|
58
138
|
// depois da montagem, então o estado inicial seria calculado com a lista vazia e o painel ficaria
|
|
59
139
|
// fechado mesmo com dados.
|
|
60
140
|
const isNarrow = useIsNarrow()
|
|
61
141
|
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
|
62
|
-
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa.
|
|
63
|
-
|
|
64
|
-
const open = manualOpen ?? (!isNarrow && filled.length > 0)
|
|
142
|
+
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa.
|
|
143
|
+
const open = manualOpen ?? defaultOpen ?? (!isNarrow && counts.completed > 0)
|
|
65
144
|
|
|
66
145
|
return (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
146
|
+
// Faixa azul clara com separador, como o wrapper do financiamento: sem ela o card encostava
|
|
147
|
+
// direto no papel de parede da conversa e os dois blocos se misturavam.
|
|
148
|
+
<div className={cn('border-b bg-blue-50/60 px-3 py-3 dark:border-gray-700 dark:bg-blue-950/20', className)}>
|
|
149
|
+
<section
|
|
150
|
+
className={cn(
|
|
151
|
+
'overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 dark:border-blue-900 dark:from-blue-950/30 dark:to-indigo-950/30',
|
|
152
|
+
classNames?.root,
|
|
153
|
+
)}
|
|
73
154
|
>
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{open ? (
|
|
84
|
-
<div className={cn('px-4 pb-3', classNames?.body)}>
|
|
85
|
-
{entries.length === 0 ? (
|
|
86
|
-
<p className="text-xs text-gray-500">{labels.empty}</p>
|
|
87
|
-
) : (
|
|
88
|
-
<dl className="grid grid-cols-2 gap-2 text-xs">
|
|
89
|
-
{entries.map((entry) => (
|
|
90
|
-
<div key={entry.key} className="min-w-0">
|
|
91
|
-
<dt className="truncate text-gray-500">
|
|
92
|
-
{entry.icon ? `${entry.icon} ` : ''}
|
|
93
|
-
{entry.label}
|
|
94
|
-
</dt>
|
|
95
|
-
<dd className="truncate font-medium">
|
|
96
|
-
{entry.value ? <span className="text-green-700 dark:text-green-400">✓ {entry.value}</span> : '—'}
|
|
97
|
-
</dd>
|
|
98
|
-
</div>
|
|
99
|
-
))}
|
|
100
|
-
</dl>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => setManualOpen(!open)}
|
|
158
|
+
aria-expanded={open}
|
|
159
|
+
data-cv-tooltip={open ? labels.collapse : labels.expand} aria-label={open ? labels.collapse : labels.expand}
|
|
160
|
+
className={cn(
|
|
161
|
+
'flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-2.5 text-left transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20',
|
|
162
|
+
classNames?.toggle,
|
|
101
163
|
)}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
>
|
|
165
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
166
|
+
<span aria-hidden className="text-base leading-none">
|
|
167
|
+
📋
|
|
168
|
+
</span>
|
|
169
|
+
<span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.title}</span>
|
|
170
|
+
{flowLabel ? (
|
|
171
|
+
<span className="truncate text-xs font-medium text-blue-600 dark:text-blue-400">{flowLabel}</span>
|
|
172
|
+
) : null}
|
|
173
|
+
<span
|
|
174
|
+
className={cn(
|
|
175
|
+
'inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-medium text-white',
|
|
176
|
+
classNames?.counter,
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
{entries.length}
|
|
180
|
+
</span>
|
|
181
|
+
</span>
|
|
182
|
+
|
|
183
|
+
<span className="flex flex-shrink-0 items-center gap-2">
|
|
184
|
+
<CountPill
|
|
185
|
+
status="completed"
|
|
186
|
+
count={counts.completed}
|
|
187
|
+
className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
188
|
+
/>
|
|
189
|
+
<CountPill
|
|
190
|
+
status="editing"
|
|
191
|
+
count={counts.editing}
|
|
192
|
+
className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
|
193
|
+
/>
|
|
194
|
+
<CountPill
|
|
195
|
+
status="pending"
|
|
196
|
+
count={counts.pending}
|
|
197
|
+
className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
198
|
+
/>
|
|
199
|
+
{open ? (
|
|
200
|
+
<ChevronUp size={15} className="flex-shrink-0 text-blue-500" aria-hidden />
|
|
201
|
+
) : (
|
|
202
|
+
<ChevronDown size={15} className="flex-shrink-0 text-blue-500" aria-hidden />
|
|
203
|
+
)}
|
|
204
|
+
</span>
|
|
205
|
+
</button>
|
|
206
|
+
|
|
207
|
+
{open ? (
|
|
208
|
+
/**
|
|
209
|
+
* Teto de altura com rolagem interna, como no financiamento (`max-h-64 overflow-y-auto`).
|
|
210
|
+
* Sem isso o painel cresce sem limite — e cresce de verdade: o host anexa as chaves de
|
|
211
|
+
* contexto que ele não conhece ao fim da lista, então uma conversa com bastante estado
|
|
212
|
+
* empurrava a conversa inteira para fora da tela.
|
|
213
|
+
*
|
|
214
|
+
* A diferença: lá o teto envolve o cabeçalho também, então ele rola junto e sai de vista.
|
|
215
|
+
* Aqui só o corpo rola — o cabeçalho é o que fecha o painel, e perder o clique de fechar
|
|
216
|
+
* no meio da rolagem é pior do que a economia de uma linha de markup.
|
|
217
|
+
*/
|
|
218
|
+
<div
|
|
219
|
+
className={cn(
|
|
220
|
+
'cv-scrollbar-thin max-h-64 overflow-y-auto border-t border-blue-200 px-4 pb-4 pt-1 dark:border-blue-900',
|
|
221
|
+
classNames?.body,
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{entries.length === 0 ? (
|
|
225
|
+
<p className="pt-2 text-xs text-gray-500 dark:text-gray-400">{labels.empty}</p>
|
|
226
|
+
) : (
|
|
227
|
+
/**
|
|
228
|
+
* Três colunas no desktop, como no financiamento — com duas, seis campos viravam uma
|
|
229
|
+
* coluna alta que empurrava a conversa.
|
|
230
|
+
*
|
|
231
|
+
* O que não fizemos igual: lá o painel FILTRA o que não foi coletado, porque a fonte
|
|
232
|
+
* só guarda o que o cliente respondeu. Aqui a lista de campos é conhecida de antemão,
|
|
233
|
+
* e "falta endereço" é justamente o que o atendente precisa ver ao assumir. Então o
|
|
234
|
+
* pendente aparece — tracejado e apagado, para ler como lacuna e não como dado.
|
|
235
|
+
*/
|
|
236
|
+
<dl className="mt-2 grid grid-cols-2 gap-2 lg:grid-cols-3">
|
|
237
|
+
{entries.map((entry) => {
|
|
238
|
+
const status = statusOf(entry)
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
key={entry.key}
|
|
243
|
+
className={cn('min-w-0 rounded-lg p-2.5 transition-all', CARD_STYLE[status])}
|
|
244
|
+
>
|
|
245
|
+
<dt className="mb-1 flex items-center justify-center gap-1">
|
|
246
|
+
{entry.icon ? (
|
|
247
|
+
<span aria-hidden className="text-base leading-none">
|
|
248
|
+
{entry.icon}
|
|
249
|
+
</span>
|
|
250
|
+
) : null}
|
|
251
|
+
<span className="truncate text-[10px] font-medium uppercase leading-none tracking-wide text-gray-500 dark:text-gray-400">
|
|
252
|
+
{entry.label}
|
|
253
|
+
</span>
|
|
254
|
+
<span className={cn('flex-shrink-0 leading-none', GLYPH_STYLE[status])}>
|
|
255
|
+
<StatusGlyph status={status} />
|
|
256
|
+
</span>
|
|
257
|
+
</dt>
|
|
258
|
+
{/* `title` no valor: truncado no card, o texto completo só existe no tooltip. */}
|
|
259
|
+
<dd
|
|
260
|
+
className={cn(
|
|
261
|
+
'truncate text-center text-xs font-semibold',
|
|
262
|
+
entry.value
|
|
263
|
+
? 'text-gray-900 dark:text-gray-100'
|
|
264
|
+
: 'italic font-normal text-gray-400 dark:text-gray-500',
|
|
265
|
+
)}
|
|
266
|
+
data-cv-tooltip={entry.value ?? labels.notCollected}
|
|
267
|
+
>
|
|
268
|
+
{entry.value ?? labels.notCollected}
|
|
269
|
+
</dd>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
})}
|
|
273
|
+
</dl>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
) : null}
|
|
277
|
+
</section>
|
|
278
|
+
</div>
|
|
105
279
|
)
|
|
106
280
|
}
|
|
@@ -13,6 +13,7 @@ import { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
|
13
13
|
import { useConversations } from './providers/ConversationsProvider'
|
|
14
14
|
import { FileIcon } from './FileIcon'
|
|
15
15
|
import { cn } from './lib/cn'
|
|
16
|
+
import { PAGINATION_LABELS } from './pagination.constant'
|
|
16
17
|
import { formatDateTime, formatFileSize } from './lib/format'
|
|
17
18
|
|
|
18
19
|
/** Origens que o filtro oferece. `all` não é origem — é a ausência de filtro. */
|
|
@@ -239,6 +240,7 @@ export function ConversationDocumentsPanel({
|
|
|
239
240
|
</select>
|
|
240
241
|
|
|
241
242
|
<button
|
|
243
|
+
data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
|
|
242
244
|
type="button"
|
|
243
245
|
onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
|
|
244
246
|
className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
|
|
@@ -250,6 +252,7 @@ export function ConversationDocumentsPanel({
|
|
|
250
252
|
{/* Só aparece com filtro ativo: botão que não faz nada visível é ruído. */}
|
|
251
253
|
{hasFilters ? (
|
|
252
254
|
<button
|
|
255
|
+
data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
|
|
253
256
|
type="button"
|
|
254
257
|
onClick={() =>
|
|
255
258
|
applyFilter(() => {
|
|
@@ -291,7 +294,7 @@ export function ConversationDocumentsPanel({
|
|
|
291
294
|
{labels.selectAll}
|
|
292
295
|
</label>
|
|
293
296
|
{selectedIds.length > 0 ? (
|
|
294
|
-
<button type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
|
|
297
|
+
<button data-cv-tooltip={labels.downloadSelected(selectedIds.length)} aria-label={labels.downloadSelected(selectedIds.length)} type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
|
|
295
298
|
{labels.downloadSelected(selectedIds.length)}
|
|
296
299
|
</button>
|
|
297
300
|
) : null}
|
|
@@ -347,7 +350,7 @@ export function ConversationDocumentsPanel({
|
|
|
347
350
|
{/* `title` no nome: truncado, o atendente não tem outro jeito de ler inteiro. */}
|
|
348
351
|
<div
|
|
349
352
|
className={cn('truncate text-sm font-medium', classNames?.filename)}
|
|
350
|
-
|
|
353
|
+
data-cv-tooltip={document.filename}
|
|
351
354
|
>
|
|
352
355
|
{document.filename}
|
|
353
356
|
</div>
|
|
@@ -366,7 +369,7 @@ export function ConversationDocumentsPanel({
|
|
|
366
369
|
<button
|
|
367
370
|
type="button"
|
|
368
371
|
onClick={() => void handleOpen(document.id, 'inline')}
|
|
369
|
-
|
|
372
|
+
data-cv-tooltip={labels.view}
|
|
370
373
|
aria-label={`${labels.view}: ${document.filename}`}
|
|
371
374
|
className={cn('cv-header-icon', classNames?.viewButton)}
|
|
372
375
|
>
|
|
@@ -375,7 +378,7 @@ export function ConversationDocumentsPanel({
|
|
|
375
378
|
<button
|
|
376
379
|
type="button"
|
|
377
380
|
onClick={() => void handleOpen(document.id, 'attachment')}
|
|
378
|
-
|
|
381
|
+
data-cv-tooltip={labels.download}
|
|
379
382
|
aria-label={`${labels.download}: ${document.filename}`}
|
|
380
383
|
className={cn('cv-header-icon', classNames?.downloadButton)}
|
|
381
384
|
>
|
|
@@ -401,7 +404,8 @@ export function ConversationDocumentsPanel({
|
|
|
401
404
|
type="button"
|
|
402
405
|
onClick={() => setPage(page - 1)}
|
|
403
406
|
disabled={page <= 1}
|
|
404
|
-
aria-label={
|
|
407
|
+
aria-label={PAGINATION_LABELS.previous}
|
|
408
|
+
data-cv-tooltip={PAGINATION_LABELS.previous}
|
|
405
409
|
className="cv-header-icon disabled:opacity-40"
|
|
406
410
|
>
|
|
407
411
|
‹
|
|
@@ -411,7 +415,8 @@ export function ConversationDocumentsPanel({
|
|
|
411
415
|
type="button"
|
|
412
416
|
onClick={() => setPage(page + 1)}
|
|
413
417
|
disabled={page >= lastPage}
|
|
414
|
-
aria-label={
|
|
418
|
+
aria-label={PAGINATION_LABELS.next}
|
|
419
|
+
data-cv-tooltip={PAGINATION_LABELS.next}
|
|
415
420
|
className="cv-header-icon disabled:opacity-40"
|
|
416
421
|
>
|
|
417
422
|
›
|