@adatechnology/conversations-ui 0.1.0-rc.0 → 0.1.0-rc.10
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-FWNAWAJP.js +1743 -0
- package/dist/flows/index.js +3 -3
- package/dist/index.d.ts +619 -136
- package/dist/index.js +1064 -1024
- package/dist/preview/index.d.ts +330 -0
- package/dist/preview/index.js +1281 -0
- package/dist/styles.css +198 -0
- package/dist/types-B5C1DLu1.d.ts +365 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +16 -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 +14 -11
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +73 -19
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +54 -6
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowMapCanvas.tsx +2 -2
- 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/index.ts +112 -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/AudioRecorderButton.tsx +125 -0
- package/src/preview/ConversationPreview.tsx +315 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/audioRecorderFormat.test.ts +67 -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/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +126 -0
- package/src/preview/index.ts +54 -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/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +163 -11
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +46 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cabeçalho do painel de conversa: quem é o cliente, quem está conduzindo (bot ou humano) e as
|
|
3
|
+
* ações de atendimento.
|
|
4
|
+
*
|
|
5
|
+
* Presentacional por decisão de arquitetura: `ConversationsApi` não tem takeover/release, e não é
|
|
6
|
+
* papel do pacote saber a rota de cada host. Quem passa os handlers é o produto.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import type { ConversationSummary } from './providers/types'
|
|
12
|
+
import { capabilitiesOf, contactFlag, formatContactHandle } from './conversationChannel'
|
|
13
|
+
import { cn } from './lib/cn'
|
|
14
|
+
import { Avatar } from './Avatar'
|
|
15
|
+
import { ChannelIcon } from './ChannelIcon'
|
|
16
|
+
|
|
17
|
+
export interface ConversationHeaderLabels {
|
|
18
|
+
botMode: string
|
|
19
|
+
humanMode: string
|
|
20
|
+
returnToBot: string
|
|
21
|
+
finish: string
|
|
22
|
+
takeover: string
|
|
23
|
+
download: string
|
|
24
|
+
documents: string
|
|
25
|
+
back: string
|
|
26
|
+
moreActions: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_CONVERSATION_HEADER_LABELS: ConversationHeaderLabels = {
|
|
30
|
+
botMode: '🤖 atendimento automático',
|
|
31
|
+
humanMode: '🧑💼 atendimento humano',
|
|
32
|
+
returnToBot: 'Devolver ao bot',
|
|
33
|
+
finish: 'Finalizar',
|
|
34
|
+
takeover: 'Assumir atendimento',
|
|
35
|
+
download: 'Baixar conversa',
|
|
36
|
+
documents: 'Arquivos da conversa',
|
|
37
|
+
back: 'Voltar para a lista',
|
|
38
|
+
moreActions: 'Mais ações',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Partes estilizáveis do cabeçalho. Cada chave recebe classes que o `cn` funde por cima da base, e
|
|
43
|
+
* conflito de utilitário (padding, gap, borda) fica com o valor do produto.
|
|
44
|
+
*/
|
|
45
|
+
export interface ConversationHeaderClassNames {
|
|
46
|
+
root: string
|
|
47
|
+
identity: string
|
|
48
|
+
name: string
|
|
49
|
+
meta: string
|
|
50
|
+
actions: string
|
|
51
|
+
desktopActions: string
|
|
52
|
+
mobileMenu: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Utilitário extra que o host pendura no cabeçalho — ícone no desktop, item de menu no celular,
|
|
57
|
+
* como os nativos. Entra por aqui, e não por um slot de ReactNode, porque é isso que preserva o
|
|
58
|
+
* comportamento responsivo: um nó solto viraria um quarto ícone em 375px, sem área de toque.
|
|
59
|
+
*/
|
|
60
|
+
export interface ConversationHeaderUtility {
|
|
61
|
+
key: string
|
|
62
|
+
/** Emoji, para casar com os utilitários nativos do cabeçalho. */
|
|
63
|
+
icon: string
|
|
64
|
+
label: string
|
|
65
|
+
run: () => void
|
|
66
|
+
active?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ConversationHeaderProps {
|
|
70
|
+
conversation: ConversationSummary
|
|
71
|
+
busy?: boolean
|
|
72
|
+
onTakeover?: () => void
|
|
73
|
+
onReturnToBot?: () => void
|
|
74
|
+
onFinish?: () => void
|
|
75
|
+
onDownload?: () => void
|
|
76
|
+
onOpenDocuments?: () => void
|
|
77
|
+
documentsOpen?: boolean
|
|
78
|
+
/** Ações do produto que não existem no contrato do pacote (ex.: ferramentas de dev). */
|
|
79
|
+
extraUtilities?: readonly ConversationHeaderUtility[]
|
|
80
|
+
onBack?: () => void
|
|
81
|
+
labels?: Partial<ConversationHeaderLabels>
|
|
82
|
+
className?: string
|
|
83
|
+
classNames?: Partial<ConversationHeaderClassNames>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function ConversationHeader({
|
|
87
|
+
conversation,
|
|
88
|
+
busy = false,
|
|
89
|
+
onTakeover,
|
|
90
|
+
onReturnToBot,
|
|
91
|
+
onFinish,
|
|
92
|
+
onDownload,
|
|
93
|
+
onOpenDocuments,
|
|
94
|
+
documentsOpen = false,
|
|
95
|
+
extraUtilities,
|
|
96
|
+
onBack,
|
|
97
|
+
labels: labelsOverride,
|
|
98
|
+
className,
|
|
99
|
+
classNames,
|
|
100
|
+
}: ConversationHeaderProps) {
|
|
101
|
+
const labels = { ...DEFAULT_CONVERSATION_HEADER_LABELS, ...labelsOverride }
|
|
102
|
+
const isHuman = conversation.mode === 'human'
|
|
103
|
+
const handle = conversation.contactId ?? conversation.whatsappNumber
|
|
104
|
+
const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
|
|
105
|
+
const flag = contactFlag({ handle, channel: conversation.channel })
|
|
106
|
+
const capabilities = capabilitiesOf(conversation.channel)
|
|
107
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
108
|
+
|
|
109
|
+
// Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
|
|
110
|
+
// caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
|
|
111
|
+
const utilities = [
|
|
112
|
+
onOpenDocuments
|
|
113
|
+
? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
|
|
114
|
+
: undefined,
|
|
115
|
+
onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
|
|
116
|
+
...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
|
|
117
|
+
].filter(
|
|
118
|
+
(utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
|
|
119
|
+
Boolean(utility),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
|
|
123
|
+
// superfícies nunca divergirem sobre o que está disponível.
|
|
124
|
+
const actions = [
|
|
125
|
+
!isHuman && onTakeover
|
|
126
|
+
? { key: 'takeover', label: labels.takeover, run: onTakeover, className: 'cv-header-action--primary' }
|
|
127
|
+
: undefined,
|
|
128
|
+
isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
|
|
129
|
+
isHuman && onFinish
|
|
130
|
+
? { key: 'finish', label: `✕ ${labels.finish}`, run: onFinish, className: 'cv-header-action--danger' }
|
|
131
|
+
: undefined,
|
|
132
|
+
].filter((action): action is { key: string; label: string; run: () => void; className: string } => Boolean(action))
|
|
133
|
+
|
|
134
|
+
// No celular utilitários e ações moram no mesmo menu: utilitários primeiro, porque são consulta e
|
|
135
|
+
// não alteram estado da conversa.
|
|
136
|
+
const menuItems = [
|
|
137
|
+
...utilities.map((utility) => ({
|
|
138
|
+
key: utility.key,
|
|
139
|
+
label: `${utility.icon} ${utility.label}`,
|
|
140
|
+
run: utility.run,
|
|
141
|
+
className: '',
|
|
142
|
+
})),
|
|
143
|
+
...actions,
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
// `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
|
|
147
|
+
// empurrava as ações para uma segunda linha — e o menu, ancorado ao próprio botão, passava a abrir
|
|
148
|
+
// fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
|
|
149
|
+
return (
|
|
150
|
+
<header
|
|
151
|
+
className={cn(
|
|
152
|
+
'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
|
|
153
|
+
classNames?.root,
|
|
154
|
+
className,
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<div className={cn('flex min-w-0 flex-1 items-center gap-3', classNames?.identity)}>
|
|
158
|
+
{/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
|
|
159
|
+
uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
|
|
160
|
+
{onBack ? (
|
|
161
|
+
<button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} title={labels.back}>
|
|
162
|
+
←
|
|
163
|
+
</button>
|
|
164
|
+
) : null}
|
|
165
|
+
{/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
|
|
166
|
+
<Avatar name={conversation.clientName} size="md" />
|
|
167
|
+
<div className="min-w-0">
|
|
168
|
+
<p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
|
|
169
|
+
<p className={cn('truncate text-xs text-gray-500', classNames?.meta)}>
|
|
170
|
+
{/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
|
|
171
|
+
o que rotulava qualquer contato como brasileiro. */}
|
|
172
|
+
{flag ? `${flag} ` : ''}
|
|
173
|
+
{displayHandle}
|
|
174
|
+
{/* Em tela estreita sobra o ícone do canal e o emoji do modo: o rótulo escrito empurra o
|
|
175
|
+
telefone para fora e não diz nada que o ícone já não diga. */}
|
|
176
|
+
<span className="ml-2 inline-flex items-center gap-1">
|
|
177
|
+
<ChannelIcon channel={conversation.channel} />
|
|
178
|
+
<span className="cv-only-wide">{capabilities.label}</span>
|
|
179
|
+
</span>
|
|
180
|
+
<span className="ml-2 cv-only-wide">{isHuman ? labels.humanMode : labels.botMode}</span>
|
|
181
|
+
<span className="ml-1 cv-only-narrow" title={isHuman ? labels.humanMode : labels.botMode}>
|
|
182
|
+
{isHuman ? '🧑💼' : '🤖'}
|
|
183
|
+
</span>
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
|
|
189
|
+
{/* Desktop: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
|
|
190
|
+
<div className={cn('hidden items-center gap-2 lg:flex', classNames?.desktopActions)}>
|
|
191
|
+
{utilities.map((utility) => (
|
|
192
|
+
<button
|
|
193
|
+
key={utility.key}
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={utility.run}
|
|
196
|
+
disabled={busy}
|
|
197
|
+
aria-pressed={utility.active}
|
|
198
|
+
title={utility.label}
|
|
199
|
+
aria-label={utility.label}
|
|
200
|
+
className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
|
|
201
|
+
>
|
|
202
|
+
{utility.icon}
|
|
203
|
+
</button>
|
|
204
|
+
))}
|
|
205
|
+
{actions.map((action) => (
|
|
206
|
+
<button
|
|
207
|
+
key={action.key}
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={action.run}
|
|
210
|
+
disabled={busy}
|
|
211
|
+
className={`cv-header-action ${action.className}`}
|
|
212
|
+
>
|
|
213
|
+
{action.label}
|
|
214
|
+
</button>
|
|
215
|
+
))}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{/* Celular: um único ⋮ com tudo dentro. Três ícones de 30px lado a lado violavam o mínimo
|
|
219
|
+
de 44px de área de toque e disputavam espaço com o nome do cliente. */}
|
|
220
|
+
{menuItems.length > 0 ? (
|
|
221
|
+
<div className={cn('cv-menu lg:hidden', classNames?.mobileMenu)}>
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
225
|
+
aria-expanded={menuOpen}
|
|
226
|
+
aria-label={labels.moreActions}
|
|
227
|
+
title={labels.moreActions}
|
|
228
|
+
className="cv-header-icon cv-touch"
|
|
229
|
+
>
|
|
230
|
+
⋮
|
|
231
|
+
</button>
|
|
232
|
+
|
|
233
|
+
{menuOpen ? (
|
|
234
|
+
<div className="cv-menu-panel" role="menu">
|
|
235
|
+
{menuItems.map((item) => (
|
|
236
|
+
<button
|
|
237
|
+
key={item.key}
|
|
238
|
+
type="button"
|
|
239
|
+
role="menuitem"
|
|
240
|
+
onClick={() => {
|
|
241
|
+
setMenuOpen(false)
|
|
242
|
+
item.run()
|
|
243
|
+
}}
|
|
244
|
+
disabled={busy}
|
|
245
|
+
className={`cv-header-action cv-touch ${item.className}`}
|
|
246
|
+
>
|
|
247
|
+
{item.label}
|
|
248
|
+
</button>
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
) : null}
|
|
252
|
+
</div>
|
|
253
|
+
) : null}
|
|
254
|
+
</div>
|
|
255
|
+
</header>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
@@ -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,14 @@ 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
|
|
16
24
|
}
|
|
17
25
|
selection: {
|
|
18
26
|
select: string
|
|
@@ -36,6 +44,14 @@ const DEFAULT_LOCALES: ConversationLocales = {
|
|
|
36
44
|
viewImage: 'Ver imagem',
|
|
37
45
|
listenAudio: 'Ouvir áudio',
|
|
38
46
|
viewVideo: 'Ver vídeo',
|
|
47
|
+
moderationFlagged: 'Linguagem ofensiva',
|
|
48
|
+
mediaLoading: 'Carregando...',
|
|
49
|
+
mediaRetry: 'Erro — tentar novamente',
|
|
50
|
+
mediaError: 'Erro',
|
|
51
|
+
mediaUnavailable: 'Mídia indisponível',
|
|
52
|
+
imageAlt: 'Imagem',
|
|
53
|
+
untitledDocument: 'Documento',
|
|
54
|
+
downloadFile: 'Baixar',
|
|
39
55
|
},
|
|
40
56
|
selection: {
|
|
41
57
|
select: 'Selecionar',
|
|
@@ -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>
|