@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21
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/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-TV4OQRGH.js +2187 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +862 -136
- package/dist/index.js +1746 -1124
- package/dist/preview/index.d.ts +470 -0
- package/dist/preview/index.js +1329 -0
- package/dist/styles.css +228 -0
- package/dist/types-C6A_9edv.d.ts +456 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +280 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +44 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -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 +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +96 -22
- package/src/MessageBubble.tsx +77 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +165 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +69 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +122 -0
- package/src/conversationTranscript.ts +89 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +140 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -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/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +334 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -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 +194 -0
- package/src/preview/createPreviewWebhookClient.ts +222 -0
- package/src/preview/index.ts +67 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- 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/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +175 -12
- package/src/quickReply.test.ts +58 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +173 -0
- package/src/types.ts +72 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import { Avatar } from './Avatar'
|
|
3
|
-
import {
|
|
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
|
|
11
24
|
onSelect?: (id: string) => void
|
|
25
|
+
/**
|
|
26
|
+
* Desliga a borda inferior quando o item é composto dentro de outra linha (ver `ConversationRow`):
|
|
27
|
+
* com ela ligada, a borda corta a própria linha ao meio, separando o item do rodapé de status.
|
|
28
|
+
*/
|
|
29
|
+
showDivider?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Desliga o fundo de selecionado. Par do `showDivider`: quando o item é composto dentro de uma
|
|
32
|
+
* linha maior, quem pinta o realce é a linha — senão só o bloco do item fica cinza e o resto
|
|
33
|
+
* (checkbox, pills, barra lateral) continua branco, como se metade da linha estivesse selecionada.
|
|
34
|
+
*/
|
|
35
|
+
highlightActive?: boolean
|
|
12
36
|
}
|
|
13
37
|
|
|
14
38
|
function formatRelativeTime(dateStr: string): string {
|
|
@@ -79,12 +103,22 @@ export const ConversationListItem = ({
|
|
|
79
103
|
selected = false,
|
|
80
104
|
onClick,
|
|
81
105
|
onSelect,
|
|
106
|
+
showDivider = true,
|
|
107
|
+
highlightActive = true,
|
|
108
|
+
labels,
|
|
82
109
|
}: ConversationListItemProps) => {
|
|
110
|
+
const expiredWindowLabel = labels?.expiredWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.expiredWindow
|
|
111
|
+
const warningWindowLabel = labels?.warningWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.warningWindow
|
|
83
112
|
const isActive = active || selected
|
|
84
113
|
const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
|
|
85
114
|
const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
|
|
86
|
-
|
|
115
|
+
// `contactId` é o identificador neutro; `whatsappNumber` fica como fallback enquanto o backend
|
|
116
|
+
// não informa canal.
|
|
117
|
+
const handle = conversation.contactId ?? conversation.whatsappNumber
|
|
118
|
+
const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
|
|
119
|
+
const name = conversation.clientName || displayHandle || handle
|
|
87
120
|
const timestamp = formatRelativeTime(conversation.lastAt)
|
|
121
|
+
const flag = contactFlag({ handle, channel: conversation.channel })
|
|
88
122
|
const isInbound = conversation.lastDirection === 'inbound'
|
|
89
123
|
|
|
90
124
|
const handleClick = () => {
|
|
@@ -95,13 +129,15 @@ export const ConversationListItem = ({
|
|
|
95
129
|
return (
|
|
96
130
|
<button
|
|
97
131
|
onClick={handleClick}
|
|
98
|
-
className=
|
|
132
|
+
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]" : ""}`}
|
|
99
133
|
style={{
|
|
100
|
-
backgroundColor: isActive ? '#f0f2f5' : 'transparent',
|
|
134
|
+
backgroundColor: isActive && highlightActive ? '#f0f2f5' : 'transparent',
|
|
101
135
|
}}
|
|
102
136
|
>
|
|
103
137
|
<div className="relative flex-shrink-0">
|
|
104
|
-
|
|
138
|
+
{/* Só o nome do cliente: passar o telefone faria o avatar exibir dígitos soltos em vez da
|
|
139
|
+
silhueta de contato sem nome. */}
|
|
140
|
+
<Avatar name={conversation.clientName} size="lg" />
|
|
105
141
|
{conversation.waitingHuman && (
|
|
106
142
|
<div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-amber-400 rounded-full border-2 border-white" />
|
|
107
143
|
)}
|
|
@@ -110,18 +146,29 @@ export const ConversationListItem = ({
|
|
|
110
146
|
<div className="flex-1 min-w-0">
|
|
111
147
|
<div className="flex items-center justify-between gap-2">
|
|
112
148
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
149
|
+
{/* Sem nome, o título já é o telefone — a bandeira vai nele. */}
|
|
150
|
+
{!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
|
|
113
151
|
<span className="text-[16px] text-[#111b21] truncate">{name}</span>
|
|
114
152
|
{windowStatus && windowStatus.label === 'expired' && (
|
|
115
|
-
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title=
|
|
153
|
+
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title={expiredWindowLabel} />
|
|
116
154
|
)}
|
|
117
155
|
{windowStatus && windowStatus.label === 'warning' && (
|
|
118
|
-
<span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title=
|
|
156
|
+
<span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title={warningWindowLabel} />
|
|
119
157
|
)}
|
|
120
158
|
</div>
|
|
121
159
|
{timestamp && (
|
|
122
160
|
<span className="text-xs text-[#667781] flex-shrink-0">{timestamp}</span>
|
|
123
161
|
)}
|
|
124
162
|
</div>
|
|
163
|
+
{/* Telefone só como subtítulo quando o título é o nome — repetir o número embaixo dele
|
|
164
|
+
mesmo gastaria uma linha para dizer duas vezes a mesma coisa. */}
|
|
165
|
+
{conversation.clientName ? (
|
|
166
|
+
<div className="flex items-center gap-1 text-xs text-[#667781]">
|
|
167
|
+
{flag ? <span aria-hidden>{flag}</span> : null}
|
|
168
|
+
<span className="truncate">{displayHandle}</span>
|
|
169
|
+
</div>
|
|
170
|
+
) : null}
|
|
171
|
+
|
|
125
172
|
<div className="flex items-center justify-between mt-0.5">
|
|
126
173
|
<span className="text-sm text-[#667781] truncate max-w-[180px]">
|
|
127
174
|
{preview.icon && <span className="mr-1">{preview.icon}</span>}
|
|
@@ -13,6 +13,28 @@ export interface ConversationLocales {
|
|
|
13
13
|
viewImage: string
|
|
14
14
|
listenAudio: string
|
|
15
15
|
viewVideo: string
|
|
16
|
+
moderationFlagged: string
|
|
17
|
+
mediaLoading: string
|
|
18
|
+
mediaRetry: string
|
|
19
|
+
mediaError: string
|
|
20
|
+
mediaUnavailable: string
|
|
21
|
+
imageAlt: string
|
|
22
|
+
untitledDocument: string
|
|
23
|
+
downloadFile: string
|
|
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
|
|
16
38
|
}
|
|
17
39
|
selection: {
|
|
18
40
|
select: string
|
|
@@ -36,6 +58,27 @@ const DEFAULT_LOCALES: ConversationLocales = {
|
|
|
36
58
|
viewImage: 'Ver imagem',
|
|
37
59
|
listenAudio: 'Ouvir áudio',
|
|
38
60
|
viewVideo: 'Ver vídeo',
|
|
61
|
+
moderationFlagged: 'Linguagem ofensiva',
|
|
62
|
+
mediaLoading: 'Carregando...',
|
|
63
|
+
mediaRetry: 'Erro — tentar novamente',
|
|
64
|
+
mediaError: 'Erro',
|
|
65
|
+
mediaUnavailable: 'Mídia indisponível',
|
|
66
|
+
imageAlt: 'Imagem',
|
|
67
|
+
untitledDocument: 'Documento',
|
|
68
|
+
downloadFile: 'Baixar',
|
|
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',
|
|
39
82
|
},
|
|
40
83
|
selection: {
|
|
41
84
|
select: 'Selecionar',
|
|
@@ -60,6 +103,7 @@ export interface ConversationLocalesProviderProps {
|
|
|
60
103
|
export function ConversationLocalesProvider({ children, locales }: ConversationLocalesProviderProps) {
|
|
61
104
|
const merged: ConversationLocales = {
|
|
62
105
|
bubble: { ...DEFAULT_LOCALES.bubble, ...locales?.bubble },
|
|
106
|
+
transcription: { ...DEFAULT_LOCALES.transcription, ...locales?.transcription },
|
|
63
107
|
selection: { ...DEFAULT_LOCALES.selection, ...locales?.selection },
|
|
64
108
|
dateDivider: { ...DEFAULT_LOCALES.dateDivider, ...locales?.dateDivider },
|
|
65
109
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linha da inbox operacional: o `ConversationListItem` cuida do visual padrão (avatar, prévia,
|
|
3
|
+
* hora, não lidas) e aqui em volta ficam as affordances de trabalho — seleção em massa, sinal da
|
|
4
|
+
* janela de 24h, tempo parada e retomada de atendimento.
|
|
5
|
+
*
|
|
6
|
+
* Fica no pacote, e não em cada produto, porque nada disto é regra de um negócio específico: é
|
|
7
|
+
* como se opera uma fila de atendimento no WhatsApp.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ConversationListItem } from './ConversationListItem'
|
|
11
|
+
import { capabilitiesOf } from './conversationChannel'
|
|
12
|
+
import { cn } from './lib/cn'
|
|
13
|
+
import { ChannelIcon } from './ChannelIcon'
|
|
14
|
+
import type { ConversationSummary } from './providers/types'
|
|
15
|
+
import {
|
|
16
|
+
CONVERSATION_WINDOW,
|
|
17
|
+
formatStalledFor,
|
|
18
|
+
windowOf,
|
|
19
|
+
type ConversationWindow,
|
|
20
|
+
} from './conversationWindow'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
24
|
+
[CONVERSATION_WINDOW.ALL]: '',
|
|
25
|
+
[CONVERSATION_WINDOW.FRESH]: 'Janela de 24h aberta (menos de 12h)',
|
|
26
|
+
[CONVERSATION_WINDOW.WARNING]: 'Janela de 24h fechando (12-21h)',
|
|
27
|
+
[CONVERSATION_WINDOW.CRITICAL]: 'Janela de 24h quase expirada (21-24h)',
|
|
28
|
+
[CONVERSATION_WINDOW.EXPIRED]: 'Janela de 24h expirada — só template',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
|
|
32
|
+
const STALLED_THRESHOLD_MS = 60 * 60 * 1000
|
|
33
|
+
|
|
34
|
+
const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
|
|
35
|
+
[CONVERSATION_WINDOW.ALL]: 'bg-transparent',
|
|
36
|
+
[CONVERSATION_WINDOW.FRESH]: 'bg-green-500',
|
|
37
|
+
[CONVERSATION_WINDOW.WARNING]: 'bg-yellow-500',
|
|
38
|
+
[CONVERSATION_WINDOW.CRITICAL]: 'bg-red-500',
|
|
39
|
+
[CONVERSATION_WINDOW.EXPIRED]: 'bg-gray-300',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ConversationRowClassNames = {
|
|
43
|
+
root: string
|
|
44
|
+
windowBar: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ConversationRowProps = {
|
|
48
|
+
conversation: ConversationSummary
|
|
49
|
+
active: boolean
|
|
50
|
+
selected: boolean
|
|
51
|
+
now: number
|
|
52
|
+
busy: boolean
|
|
53
|
+
onOpen: () => void
|
|
54
|
+
onToggleSelected: () => void
|
|
55
|
+
onTakeover: () => void
|
|
56
|
+
className?: string
|
|
57
|
+
classNames?: Partial<ConversationRowClassNames>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ConversationRow({
|
|
61
|
+
conversation,
|
|
62
|
+
active,
|
|
63
|
+
selected,
|
|
64
|
+
now,
|
|
65
|
+
busy,
|
|
66
|
+
onOpen,
|
|
67
|
+
onToggleSelected,
|
|
68
|
+
onTakeover,
|
|
69
|
+
className,
|
|
70
|
+
classNames,
|
|
71
|
+
}: ConversationRowProps) {
|
|
72
|
+
const capabilities = capabilitiesOf(conversation.channel)
|
|
73
|
+
const window = windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel })
|
|
74
|
+
const stalledMs = now - new Date(conversation.lastAt).getTime()
|
|
75
|
+
// "Parada" é sobre a conversa estar sem resposta há tempo, independente de quem conduz. Amarrar
|
|
76
|
+
// ao `waitingHuman` escondia justamente o caso ruim: conversa assumida e esquecida.
|
|
77
|
+
const isStalled = stalledMs > STALLED_THRESHOLD_MS
|
|
78
|
+
const isWaiting = conversation.mode === 'bot' && conversation.waitingHuman
|
|
79
|
+
|
|
80
|
+
// Realce e hover ficam na linha inteira: com eles no item, só o bloco dele ficava cinza enquanto
|
|
81
|
+
// checkbox, barra lateral e pills continuavam brancos — parecia meia linha selecionada.
|
|
82
|
+
return (
|
|
83
|
+
<div className={cn('cv-row flex border-b', active && 'cv-row--active', classNames?.root, className)}>
|
|
84
|
+
{/* Barra lateral com a cor da janela: informa sem competir por espaço com o conteúdo, e não
|
|
85
|
+
duplica as bolinhas que o próprio ConversationListItem já desenha. */}
|
|
86
|
+
<span
|
|
87
|
+
className={cn('w-1 shrink-0', WINDOW_BAR_CLASS[window], classNames?.windowBar)}
|
|
88
|
+
title={WINDOW_TITLE[window]}
|
|
89
|
+
aria-label={WINDOW_TITLE[window]}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
<div className="min-w-0 flex-1">
|
|
93
|
+
<div className="flex items-start gap-2 px-2 pt-2">
|
|
94
|
+
<input
|
|
95
|
+
type="checkbox"
|
|
96
|
+
checked={selected}
|
|
97
|
+
onChange={onToggleSelected}
|
|
98
|
+
aria-label={`Selecionar conversa ${conversation.whatsappNumber}`}
|
|
99
|
+
className="mt-3"
|
|
100
|
+
/>
|
|
101
|
+
<div className="min-w-0 flex-1">
|
|
102
|
+
{/* Sem divisória: a borda do item cortaria esta linha ao meio, separando-o do rodapé
|
|
103
|
+
de status que pertence à mesma conversa. */}
|
|
104
|
+
<ConversationListItem
|
|
105
|
+
conversation={conversation}
|
|
106
|
+
active={active}
|
|
107
|
+
onClick={onOpen}
|
|
108
|
+
showDivider={false}
|
|
109
|
+
highlightActive={false}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-2 pl-8">
|
|
115
|
+
{/* Canal na linha: numa inbox multicanal, saber de onde a conversa veio muda o que o
|
|
116
|
+
atendente pode fazer nela. */}
|
|
117
|
+
<span className="cv-pill">
|
|
118
|
+
<ChannelIcon channel={conversation.channel} /> {capabilities.label}
|
|
119
|
+
</span>
|
|
120
|
+
{conversation.mode === 'human' ? (
|
|
121
|
+
<span className="cv-pill cv-pill--success">🧑💼 em atendimento</span>
|
|
122
|
+
) : 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
|
+
{isStalled ? (
|
|
126
|
+
<span className="cv-pill cv-pill--danger">⏱️ parada há {formatStalledFor(conversation.lastAt, now)}</span>
|
|
127
|
+
) : null}
|
|
128
|
+
{isWaiting ? (
|
|
129
|
+
<button type="button" onClick={onTakeover} disabled={busy} className="cv-header-action">
|
|
130
|
+
▶️ Continuar Atendimento
|
|
131
|
+
</button>
|
|
132
|
+
) : null}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
package/src/DateDivider.tsx
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
2
2
|
import { isSameDay } from './lib/format'
|
|
3
|
+
import { cn } from './lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface DateDividerClassNames {
|
|
6
|
+
root: string
|
|
7
|
+
label: string
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
export interface DateDividerProps {
|
|
5
11
|
iso: string
|
|
12
|
+
className?: string
|
|
13
|
+
classNames?: Partial<DateDividerClassNames>
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/DateDivider.tsx —
|
|
9
17
|
// sticky no topo do scroll, "Hoje"/"Ontem" localizados, senão data completa pt-BR.
|
|
10
|
-
export function DateDivider({ iso }: DateDividerProps) {
|
|
18
|
+
export function DateDivider({ iso, className, classNames }: DateDividerProps) {
|
|
11
19
|
const { dateDivider } = useConversationLocales()
|
|
12
20
|
|
|
13
21
|
return (
|
|
14
|
-
<div className=
|
|
15
|
-
<span
|
|
22
|
+
<div className={cn('flex justify-center sticky top-0 z-10 my-2 pointer-events-none', classNames?.root, className)}>
|
|
23
|
+
<span
|
|
24
|
+
className={cn(
|
|
25
|
+
'bg-white/95 dark:bg-gray-800/95 text-gray-600 dark:text-gray-300 text-xs font-medium px-3 py-1 rounded-lg shadow-sm',
|
|
26
|
+
classNames?.label,
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
16
29
|
{formatDividerLabel(iso, dateDivider)}
|
|
17
30
|
</span>
|
|
18
31
|
</div>
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Biblioteca de arquivos de TODAS as conversas — a tela de Documentos do painel, fora do
|
|
3
|
+
* atendimento.
|
|
4
|
+
*
|
|
5
|
+
* Distinta do `ConversationDocumentsPanel`: aquele parte de uma conversa aberta e vive dentro dela;
|
|
6
|
+
* esta varre a empresa e por isso mostra de qual conversa cada arquivo veio, com o número
|
|
7
|
+
* clicável. Sem essa referência, uma lista global de anexos não responde nenhuma pergunta.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from 'react'
|
|
11
|
+
import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Users } from 'lucide-react'
|
|
12
|
+
import { useConversations } from './providers/ConversationsProvider'
|
|
13
|
+
import { DOCUMENT_SOURCE_FILTER, type DocumentSourceFilter } from './ConversationDocumentsPanel'
|
|
14
|
+
import { FileIcon } from './FileIcon'
|
|
15
|
+
import { cn } from './lib/cn'
|
|
16
|
+
import { formatDateTime, formatFileSize } from './lib/format'
|
|
17
|
+
import { formatPhone } from './lib/phone'
|
|
18
|
+
import type { CompanyDocument } from './providers/types'
|
|
19
|
+
|
|
20
|
+
export interface DocumentsLibraryLabels {
|
|
21
|
+
title: string
|
|
22
|
+
searchPlaceholder: string
|
|
23
|
+
empty: string
|
|
24
|
+
noResults: string
|
|
25
|
+
loading: string
|
|
26
|
+
failure: string
|
|
27
|
+
view: string
|
|
28
|
+
download: string
|
|
29
|
+
openConversation: string
|
|
30
|
+
sourceFilterAll: string
|
|
31
|
+
sourceFilterCustomer: string
|
|
32
|
+
sourceFilterTeam: string
|
|
33
|
+
sortMostRecent: string
|
|
34
|
+
sortOldest: string
|
|
35
|
+
clearFilters: string
|
|
36
|
+
total: (count: number) => string
|
|
37
|
+
page: (current: number, last: number) => string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
|
|
41
|
+
title: 'Documentos',
|
|
42
|
+
searchPlaceholder: 'Buscar por nome do arquivo ou telefone',
|
|
43
|
+
empty: 'Nenhum arquivo trocado ainda.',
|
|
44
|
+
noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
|
|
45
|
+
loading: 'Carregando arquivos…',
|
|
46
|
+
failure: 'Não foi possível carregar os arquivos.',
|
|
47
|
+
view: 'Visualizar',
|
|
48
|
+
download: 'Baixar',
|
|
49
|
+
openConversation: 'Abrir conversa',
|
|
50
|
+
sourceFilterAll: 'Todas as origens',
|
|
51
|
+
sourceFilterCustomer: 'Cliente',
|
|
52
|
+
sourceFilterTeam: 'Equipe',
|
|
53
|
+
sortMostRecent: 'Mais recentes',
|
|
54
|
+
sortOldest: 'Mais antigos',
|
|
55
|
+
clearFilters: 'Limpar filtros',
|
|
56
|
+
total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
|
|
57
|
+
page: (current: number, last: number) => `${current} / ${last}`,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DocumentsLibraryClassNames {
|
|
61
|
+
root: string
|
|
62
|
+
title: string
|
|
63
|
+
filters: string
|
|
64
|
+
search: string
|
|
65
|
+
sourceSelect: string
|
|
66
|
+
sortButton: string
|
|
67
|
+
clearButton: string
|
|
68
|
+
status: string
|
|
69
|
+
list: string
|
|
70
|
+
item: string
|
|
71
|
+
conversationLink: string
|
|
72
|
+
filename: string
|
|
73
|
+
meta: string
|
|
74
|
+
pagination: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DocumentsLibraryProps {
|
|
78
|
+
/** Itens por página. O total vem do servidor. */
|
|
79
|
+
perPage?: number
|
|
80
|
+
/** Abrir a conversa de origem. Ausente, o número aparece como texto e não como link. */
|
|
81
|
+
onOpenConversation?: (conversationId: string) => void
|
|
82
|
+
labels?: Partial<DocumentsLibraryLabels>
|
|
83
|
+
className?: string
|
|
84
|
+
classNames?: Partial<DocumentsLibraryClassNames>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TEAM_SOURCES = new Set(['agent', 'bot'])
|
|
88
|
+
const DEFAULT_PER_PAGE = 20
|
|
89
|
+
|
|
90
|
+
export function DocumentsLibrary({
|
|
91
|
+
perPage = DEFAULT_PER_PAGE,
|
|
92
|
+
onOpenConversation,
|
|
93
|
+
labels: labelsOverride,
|
|
94
|
+
className,
|
|
95
|
+
classNames,
|
|
96
|
+
}: DocumentsLibraryProps) {
|
|
97
|
+
const labels = { ...DEFAULT_DOCUMENTS_LIBRARY_LABELS, ...labelsOverride }
|
|
98
|
+
const context = useConversations()
|
|
99
|
+
const [search, setSearch] = useState('')
|
|
100
|
+
const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
|
|
101
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
|
102
|
+
const [page, setPage] = useState(1)
|
|
103
|
+
const [documents, setDocuments] = useState<readonly CompanyDocument[]>([])
|
|
104
|
+
const [total, setTotal] = useState(0)
|
|
105
|
+
const [loading, setLoading] = useState(false)
|
|
106
|
+
const [failed, setFailed] = useState(false)
|
|
107
|
+
|
|
108
|
+
const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
|
|
109
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
|
110
|
+
const fetchAll = context?.api.getAllDocuments
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!fetchAll) return
|
|
114
|
+
let active = true
|
|
115
|
+
setLoading(true)
|
|
116
|
+
setFailed(false)
|
|
117
|
+
|
|
118
|
+
void fetchAll({
|
|
119
|
+
search,
|
|
120
|
+
page,
|
|
121
|
+
limit: perPage,
|
|
122
|
+
sortDirection,
|
|
123
|
+
...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
|
|
124
|
+
})
|
|
125
|
+
.then((result) => {
|
|
126
|
+
// `active` evita que uma resposta antiga sobrescreva a nova: digitar rápido na busca dispara
|
|
127
|
+
// várias chamadas e a ordem de retorno não é garantida.
|
|
128
|
+
if (!active) return
|
|
129
|
+
setDocuments(result.documents)
|
|
130
|
+
setTotal(result.total)
|
|
131
|
+
})
|
|
132
|
+
.catch(() => {
|
|
133
|
+
if (active) setFailed(true)
|
|
134
|
+
})
|
|
135
|
+
.finally(() => {
|
|
136
|
+
if (active) setLoading(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return () => {
|
|
140
|
+
active = false
|
|
141
|
+
}
|
|
142
|
+
}, [fetchAll, search, sourceFilter, sortDirection, page, perPage])
|
|
143
|
+
|
|
144
|
+
function applyFilter(change: () => void): void {
|
|
145
|
+
change()
|
|
146
|
+
setPage(1)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
|
|
150
|
+
const url = await context?.api.getDocumentUrl(uploadId, disposition)
|
|
151
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
|
|
155
|
+
// sempre e fazer parecer que a empresa não tem arquivo nenhum.
|
|
156
|
+
if (!fetchAll) return null
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className={cn('space-y-3', classNames?.root, className)}>
|
|
160
|
+
<h2 className={cn('text-lg font-semibold', classNames?.title)}>{labels.title}</h2>
|
|
161
|
+
|
|
162
|
+
<div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
|
|
163
|
+
<input
|
|
164
|
+
type="search"
|
|
165
|
+
value={search}
|
|
166
|
+
onChange={(event) => applyFilter(() => setSearch(event.target.value))}
|
|
167
|
+
placeholder={labels.searchPlaceholder}
|
|
168
|
+
aria-label={labels.searchPlaceholder}
|
|
169
|
+
className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-64', classNames?.search)}
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
<select
|
|
173
|
+
value={sourceFilter}
|
|
174
|
+
onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
|
|
175
|
+
aria-label={labels.sourceFilterAll}
|
|
176
|
+
className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-40', classNames?.sourceSelect)}
|
|
177
|
+
>
|
|
178
|
+
<option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
|
|
179
|
+
<option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
|
|
180
|
+
<option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
|
|
181
|
+
</select>
|
|
182
|
+
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
|
|
186
|
+
className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
|
|
187
|
+
>
|
|
188
|
+
<ArrowUpDown size={14} />
|
|
189
|
+
{sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
|
|
190
|
+
</button>
|
|
191
|
+
|
|
192
|
+
{hasFilters ? (
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={() =>
|
|
196
|
+
applyFilter(() => {
|
|
197
|
+
setSearch('')
|
|
198
|
+
setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
|
|
199
|
+
setSortDirection('desc')
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
className={cn('cv-header-action', classNames?.clearButton)}
|
|
203
|
+
>
|
|
204
|
+
{labels.clearFilters}
|
|
205
|
+
</button>
|
|
206
|
+
) : null}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
|
|
210
|
+
{failed ? (
|
|
211
|
+
<p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
|
|
212
|
+
{labels.failure}
|
|
213
|
+
</p>
|
|
214
|
+
) : null}
|
|
215
|
+
{!loading && !failed && documents.length === 0 ? (
|
|
216
|
+
<p className={cn('text-sm text-gray-500', classNames?.status)}>
|
|
217
|
+
{hasFilters ? labels.noResults : labels.empty}
|
|
218
|
+
</p>
|
|
219
|
+
) : null}
|
|
220
|
+
|
|
221
|
+
<ul className={cn('space-y-2', classNames?.list)}>
|
|
222
|
+
{documents.map((document) => {
|
|
223
|
+
const isFromCustomer = !TEAM_SOURCES.has(document.source)
|
|
224
|
+
const SourceIcon = isFromCustomer ? Users : Bot
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<li
|
|
228
|
+
key={`${document.conversationId}:${document.id}`}
|
|
229
|
+
className={cn(
|
|
230
|
+
'flex items-center justify-between gap-3 rounded-lg border px-3 py-2 dark:border-gray-700',
|
|
231
|
+
classNames?.item,
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
235
|
+
<FileIcon filename={document.filename} mimeType={document.mimeType} />
|
|
236
|
+
<div className="min-w-0 flex-1">
|
|
237
|
+
<div className={cn('truncate text-sm font-medium', classNames?.filename)} title={document.filename}>
|
|
238
|
+
{document.filename}
|
|
239
|
+
</div>
|
|
240
|
+
<div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
|
|
241
|
+
<span className="inline-flex items-center gap-1">
|
|
242
|
+
<SourceIcon size={11} />
|
|
243
|
+
{isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
|
|
244
|
+
</span>
|
|
245
|
+
<span>·</span>
|
|
246
|
+
<span>{formatDateTime(document.linkedAt)}</span>
|
|
247
|
+
<span>·</span>
|
|
248
|
+
<span>{formatFileSize(document.sizeBytes)}</span>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* A conversa de origem é o dado que só esta tela tem. Vira botão quando o host sabe
|
|
254
|
+
navegar; sem handler, fica texto — link que não leva a lugar nenhum é pior. */}
|
|
255
|
+
{onOpenConversation ? (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={() => onOpenConversation(document.conversationId)}
|
|
259
|
+
title={labels.openConversation}
|
|
260
|
+
className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
|
|
261
|
+
>
|
|
262
|
+
<MessageSquare size={12} />
|
|
263
|
+
{formatPhone(document.conversationId)}
|
|
264
|
+
</button>
|
|
265
|
+
) : (
|
|
266
|
+
<span className={cn('shrink-0 text-xs text-gray-500', classNames?.conversationLink)}>
|
|
267
|
+
{formatPhone(document.conversationId)}
|
|
268
|
+
</span>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
<div className="flex shrink-0 gap-1">
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={() => void handleOpen(document.id, 'inline')}
|
|
275
|
+
title={labels.view}
|
|
276
|
+
aria-label={`${labels.view}: ${document.filename}`}
|
|
277
|
+
className="cv-header-icon"
|
|
278
|
+
>
|
|
279
|
+
<Eye size={14} />
|
|
280
|
+
</button>
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
onClick={() => void handleOpen(document.id, 'attachment')}
|
|
284
|
+
title={labels.download}
|
|
285
|
+
aria-label={`${labels.download}: ${document.filename}`}
|
|
286
|
+
className="cv-header-icon"
|
|
287
|
+
>
|
|
288
|
+
<Download size={14} />
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
</li>
|
|
292
|
+
)
|
|
293
|
+
})}
|
|
294
|
+
</ul>
|
|
295
|
+
|
|
296
|
+
{total > perPage ? (
|
|
297
|
+
<div className={cn('flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700', classNames?.pagination)}>
|
|
298
|
+
<span className="text-gray-400">{labels.total(total)}</span>
|
|
299
|
+
<div className="flex items-center gap-2">
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={() => setPage(page - 1)}
|
|
303
|
+
disabled={page <= 1}
|
|
304
|
+
className="cv-header-icon disabled:opacity-40"
|
|
305
|
+
>
|
|
306
|
+
‹
|
|
307
|
+
</button>
|
|
308
|
+
<span className="text-gray-500">{labels.page(page, lastPage)}</span>
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={() => setPage(page + 1)}
|
|
312
|
+
disabled={page >= lastPage}
|
|
313
|
+
className="cv-header-icon disabled:opacity-40"
|
|
314
|
+
>
|
|
315
|
+
›
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
) : null}
|
|
320
|
+
</div>
|
|
321
|
+
)
|
|
322
|
+
}
|