@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.4
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-4R6Y43DQ.js +726 -0
- package/dist/chunk-NV2RZ5KT.js +56 -0
- package/dist/{chunk-ZDURDZTM.js → chunk-OGRRHQQW.js} +1 -41
- package/dist/flows/index.js +6 -4
- package/dist/index.d.ts +323 -111
- package/dist/index.js +1032 -954
- package/dist/preview/index.d.ts +172 -0
- package/dist/preview/index.js +576 -0
- package/dist/styles.css +198 -0
- package/dist/types-C0PtaO7S.d.ts +207 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +18 -3
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -0
- package/src/ConversationDocumentsPanel.tsx +107 -0
- package/src/ConversationHeader.tsx +239 -0
- package/src/ConversationListItem.tsx +36 -5
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/MediaRenderer.tsx +9 -9
- package/src/MessageBubble.tsx +24 -2
- package/src/MessageComposer.tsx +15 -2
- package/src/Wallpaper.tsx +4 -2
- 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/flows/FlowMapCanvas.tsx +2 -2
- package/src/hooks/useConversationDocuments.ts +4 -2
- package/src/index.ts +73 -4
- package/src/lib/cn.ts +15 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +148 -0
- package/src/preview/createMockConversationsApi.ts +111 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -0
- package/src/preview/index.ts +40 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +175 -0
- package/src/preview/previewFixtures.ts +153 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +36 -2
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +8 -0
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arquivos trocados na conversa. Sai do transcript e vira lista própria porque anexo é o que o
|
|
3
|
+
* atendente mais precisa reencontrar depois — rolar meses de mensagens para achar um comprovante é
|
|
4
|
+
* o caso que a busca por documento existe para eliminar.
|
|
5
|
+
*
|
|
6
|
+
* Usa `useConversationDocuments`, então funciona com qualquer `ConversationsApi`. Host sem
|
|
7
|
+
* biblioteca de documentos cai no estado vazio, sem quebrar.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState } from 'react'
|
|
11
|
+
import { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
12
|
+
import { useConversations } from './providers/ConversationsProvider'
|
|
13
|
+
import { FileIcon } from './FileIcon'
|
|
14
|
+
import { cn } from './lib/cn'
|
|
15
|
+
import { formatFileSize, formatTimestamp } from './lib/format'
|
|
16
|
+
|
|
17
|
+
export interface ConversationDocumentsPanelLabels {
|
|
18
|
+
toggle: string
|
|
19
|
+
title: string
|
|
20
|
+
searchPlaceholder: string
|
|
21
|
+
empty: string
|
|
22
|
+
loading: string
|
|
23
|
+
failure: string
|
|
24
|
+
download: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_CONVERSATION_DOCUMENTS_LABELS: ConversationDocumentsPanelLabels = {
|
|
28
|
+
toggle: '📎 Arquivos',
|
|
29
|
+
title: 'Arquivos da conversa',
|
|
30
|
+
searchPlaceholder: 'Buscar arquivo...',
|
|
31
|
+
empty: 'Nenhum arquivo nesta conversa.',
|
|
32
|
+
loading: 'Carregando arquivos…',
|
|
33
|
+
failure: 'Não foi possível carregar os arquivos.',
|
|
34
|
+
download: 'Baixar',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ConversationDocumentsPanelClassNames {
|
|
38
|
+
root: string
|
|
39
|
+
body: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ConversationDocumentsPanelProps {
|
|
43
|
+
conversationId: string
|
|
44
|
+
/** Controlado de fora porque o gatilho vive no cabeçalho, junto das outras ações da conversa. */
|
|
45
|
+
open: boolean
|
|
46
|
+
labels?: Partial<ConversationDocumentsPanelLabels>
|
|
47
|
+
className?: string
|
|
48
|
+
classNames?: Partial<ConversationDocumentsPanelClassNames>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ConversationDocumentsPanel({
|
|
52
|
+
conversationId,
|
|
53
|
+
open,
|
|
54
|
+
labels: labelsOverride,
|
|
55
|
+
className,
|
|
56
|
+
classNames,
|
|
57
|
+
}: ConversationDocumentsPanelProps) {
|
|
58
|
+
const labels = { ...DEFAULT_CONVERSATION_DOCUMENTS_LABELS, ...labelsOverride }
|
|
59
|
+
const context = useConversations()
|
|
60
|
+
const [search, setSearch] = useState('')
|
|
61
|
+
|
|
62
|
+
// Só busca quando o painel abre: a lista de anexos é consulta extra e não deve pesar em toda
|
|
63
|
+
// conversa aberta.
|
|
64
|
+
const { documents, loading, error } = useConversationDocuments(open ? conversationId : undefined, { search })
|
|
65
|
+
|
|
66
|
+
async function handleDownload(uploadId: string): Promise<void> {
|
|
67
|
+
const url = await context?.api.getDocumentUrl(uploadId)
|
|
68
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!open) return null
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={cn('border-b', classNames?.root, className)}>
|
|
75
|
+
<section className={cn('px-4 py-3', classNames?.body)}>
|
|
76
|
+
<p className="mb-2 text-sm font-medium">{labels.title}</p>
|
|
77
|
+
<input
|
|
78
|
+
type="search"
|
|
79
|
+
value={search}
|
|
80
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
81
|
+
placeholder={labels.searchPlaceholder}
|
|
82
|
+
className="mb-2 w-full rounded-md border px-3 py-2 text-sm"
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
{loading ? <p className="text-xs text-gray-500">{labels.loading}</p> : null}
|
|
86
|
+
{error ? <p className="text-xs text-red-600 dark:text-red-400">{labels.failure}</p> : null}
|
|
87
|
+
{!loading && !error && documents.length === 0 ? (
|
|
88
|
+
<p className="text-xs text-gray-500">{labels.empty}</p>
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
<ul className="space-y-1">
|
|
92
|
+
{documents.map((document) => (
|
|
93
|
+
<li key={document.id} className="flex items-center gap-2 text-xs">
|
|
94
|
+
<FileIcon mimeType={document.mimeType} />
|
|
95
|
+
<span className="min-w-0 flex-1 truncate">{document.filename}</span>
|
|
96
|
+
<span className="text-gray-500">{formatFileSize(document.sizeBytes)}</span>
|
|
97
|
+
<span className="text-gray-500">{formatTimestamp(document.linkedAt)}</span>
|
|
98
|
+
<button type="button" onClick={() => void handleDownload(document.id)} className="cv-header-action">
|
|
99
|
+
{labels.download}
|
|
100
|
+
</button>
|
|
101
|
+
</li>
|
|
102
|
+
))}
|
|
103
|
+
</ul>
|
|
104
|
+
</section>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
export interface ConversationHeaderProps {
|
|
56
|
+
conversation: ConversationSummary
|
|
57
|
+
busy?: boolean
|
|
58
|
+
onTakeover?: () => void
|
|
59
|
+
onReturnToBot?: () => void
|
|
60
|
+
onFinish?: () => void
|
|
61
|
+
onDownload?: () => void
|
|
62
|
+
onOpenDocuments?: () => void
|
|
63
|
+
documentsOpen?: boolean
|
|
64
|
+
onBack?: () => void
|
|
65
|
+
labels?: Partial<ConversationHeaderLabels>
|
|
66
|
+
className?: string
|
|
67
|
+
classNames?: Partial<ConversationHeaderClassNames>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ConversationHeader({
|
|
71
|
+
conversation,
|
|
72
|
+
busy = false,
|
|
73
|
+
onTakeover,
|
|
74
|
+
onReturnToBot,
|
|
75
|
+
onFinish,
|
|
76
|
+
onDownload,
|
|
77
|
+
onOpenDocuments,
|
|
78
|
+
documentsOpen = false,
|
|
79
|
+
onBack,
|
|
80
|
+
labels: labelsOverride,
|
|
81
|
+
className,
|
|
82
|
+
classNames,
|
|
83
|
+
}: ConversationHeaderProps) {
|
|
84
|
+
const labels = { ...DEFAULT_CONVERSATION_HEADER_LABELS, ...labelsOverride }
|
|
85
|
+
const isHuman = conversation.mode === 'human'
|
|
86
|
+
const handle = conversation.contactId ?? conversation.whatsappNumber
|
|
87
|
+
const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
|
|
88
|
+
const flag = contactFlag({ handle, channel: conversation.channel })
|
|
89
|
+
const capabilities = capabilitiesOf(conversation.channel)
|
|
90
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
91
|
+
|
|
92
|
+
// Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
|
|
93
|
+
// caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
|
|
94
|
+
const utilities = [
|
|
95
|
+
onOpenDocuments
|
|
96
|
+
? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
|
|
97
|
+
: undefined,
|
|
98
|
+
onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
|
|
99
|
+
].filter(
|
|
100
|
+
(utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
|
|
101
|
+
Boolean(utility),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
|
|
105
|
+
// superfícies nunca divergirem sobre o que está disponível.
|
|
106
|
+
const actions = [
|
|
107
|
+
!isHuman && onTakeover
|
|
108
|
+
? { key: 'takeover', label: labels.takeover, run: onTakeover, className: 'cv-header-action--primary' }
|
|
109
|
+
: undefined,
|
|
110
|
+
isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
|
|
111
|
+
isHuman && onFinish
|
|
112
|
+
? { key: 'finish', label: `✕ ${labels.finish}`, run: onFinish, className: 'cv-header-action--danger' }
|
|
113
|
+
: undefined,
|
|
114
|
+
].filter((action): action is { key: string; label: string; run: () => void; className: string } => Boolean(action))
|
|
115
|
+
|
|
116
|
+
// No celular utilitários e ações moram no mesmo menu: utilitários primeiro, porque são consulta e
|
|
117
|
+
// não alteram estado da conversa.
|
|
118
|
+
const menuItems = [
|
|
119
|
+
...utilities.map((utility) => ({
|
|
120
|
+
key: utility.key,
|
|
121
|
+
label: `${utility.icon} ${utility.label}`,
|
|
122
|
+
run: utility.run,
|
|
123
|
+
className: '',
|
|
124
|
+
})),
|
|
125
|
+
...actions,
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
// `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
|
|
129
|
+
// empurrava as ações para uma segunda linha — e o menu, ancorado ao próprio botão, passava a abrir
|
|
130
|
+
// fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
|
|
131
|
+
return (
|
|
132
|
+
<header
|
|
133
|
+
className={cn(
|
|
134
|
+
'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
|
|
135
|
+
classNames?.root,
|
|
136
|
+
className,
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<div className={cn('flex min-w-0 flex-1 items-center gap-3', classNames?.identity)}>
|
|
140
|
+
{/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
|
|
141
|
+
uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
|
|
142
|
+
{onBack ? (
|
|
143
|
+
<button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} title={labels.back}>
|
|
144
|
+
←
|
|
145
|
+
</button>
|
|
146
|
+
) : null}
|
|
147
|
+
{/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
|
|
148
|
+
<Avatar name={conversation.clientName} size="md" />
|
|
149
|
+
<div className="min-w-0">
|
|
150
|
+
<p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
|
|
151
|
+
<p className={cn('truncate text-xs text-gray-500', classNames?.meta)}>
|
|
152
|
+
{/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
|
|
153
|
+
o que rotulava qualquer contato como brasileiro. */}
|
|
154
|
+
{flag ? `${flag} ` : ''}
|
|
155
|
+
{displayHandle}
|
|
156
|
+
{/* Em tela estreita sobra o ícone do canal e o emoji do modo: o rótulo escrito empurra o
|
|
157
|
+
telefone para fora e não diz nada que o ícone já não diga. */}
|
|
158
|
+
<span className="ml-2 inline-flex items-center gap-1">
|
|
159
|
+
<ChannelIcon channel={conversation.channel} />
|
|
160
|
+
<span className="cv-only-wide">{capabilities.label}</span>
|
|
161
|
+
</span>
|
|
162
|
+
<span className="ml-2 cv-only-wide">{isHuman ? labels.humanMode : labels.botMode}</span>
|
|
163
|
+
<span className="ml-1 cv-only-narrow" title={isHuman ? labels.humanMode : labels.botMode}>
|
|
164
|
+
{isHuman ? '🧑💼' : '🤖'}
|
|
165
|
+
</span>
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
|
|
171
|
+
{/* Desktop: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
|
|
172
|
+
<div className={cn('hidden items-center gap-2 lg:flex', classNames?.desktopActions)}>
|
|
173
|
+
{utilities.map((utility) => (
|
|
174
|
+
<button
|
|
175
|
+
key={utility.key}
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={utility.run}
|
|
178
|
+
disabled={busy}
|
|
179
|
+
aria-pressed={utility.active}
|
|
180
|
+
title={utility.label}
|
|
181
|
+
aria-label={utility.label}
|
|
182
|
+
className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
|
|
183
|
+
>
|
|
184
|
+
{utility.icon}
|
|
185
|
+
</button>
|
|
186
|
+
))}
|
|
187
|
+
{actions.map((action) => (
|
|
188
|
+
<button
|
|
189
|
+
key={action.key}
|
|
190
|
+
type="button"
|
|
191
|
+
onClick={action.run}
|
|
192
|
+
disabled={busy}
|
|
193
|
+
className={`cv-header-action ${action.className}`}
|
|
194
|
+
>
|
|
195
|
+
{action.label}
|
|
196
|
+
</button>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Celular: um único ⋮ com tudo dentro. Três ícones de 30px lado a lado violavam o mínimo
|
|
201
|
+
de 44px de área de toque e disputavam espaço com o nome do cliente. */}
|
|
202
|
+
{menuItems.length > 0 ? (
|
|
203
|
+
<div className={cn('cv-menu lg:hidden', classNames?.mobileMenu)}>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
207
|
+
aria-expanded={menuOpen}
|
|
208
|
+
aria-label={labels.moreActions}
|
|
209
|
+
title={labels.moreActions}
|
|
210
|
+
className="cv-header-icon cv-touch"
|
|
211
|
+
>
|
|
212
|
+
⋮
|
|
213
|
+
</button>
|
|
214
|
+
|
|
215
|
+
{menuOpen ? (
|
|
216
|
+
<div className="cv-menu-panel" role="menu">
|
|
217
|
+
{menuItems.map((item) => (
|
|
218
|
+
<button
|
|
219
|
+
key={item.key}
|
|
220
|
+
type="button"
|
|
221
|
+
role="menuitem"
|
|
222
|
+
onClick={() => {
|
|
223
|
+
setMenuOpen(false)
|
|
224
|
+
item.run()
|
|
225
|
+
}}
|
|
226
|
+
disabled={busy}
|
|
227
|
+
className={`cv-header-action cv-touch ${item.className}`}
|
|
228
|
+
>
|
|
229
|
+
{item.label}
|
|
230
|
+
</button>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
) : null}
|
|
234
|
+
</div>
|
|
235
|
+
) : null}
|
|
236
|
+
</div>
|
|
237
|
+
</header>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
export interface ConversationListItemProps {
|
|
@@ -9,6 +9,17 @@ export interface ConversationListItemProps {
|
|
|
9
9
|
selected?: boolean
|
|
10
10
|
onClick?: () => void
|
|
11
11
|
onSelect?: (id: string) => void
|
|
12
|
+
/**
|
|
13
|
+
* Desliga a borda inferior quando o item é composto dentro de outra linha (ver `ConversationRow`):
|
|
14
|
+
* com ela ligada, a borda corta a própria linha ao meio, separando o item do rodapé de status.
|
|
15
|
+
*/
|
|
16
|
+
showDivider?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Desliga o fundo de selecionado. Par do `showDivider`: quando o item é composto dentro de uma
|
|
19
|
+
* linha maior, quem pinta o realce é a linha — senão só o bloco do item fica cinza e o resto
|
|
20
|
+
* (checkbox, pills, barra lateral) continua branco, como se metade da linha estivesse selecionada.
|
|
21
|
+
*/
|
|
22
|
+
highlightActive?: boolean
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
function formatRelativeTime(dateStr: string): string {
|
|
@@ -79,12 +90,19 @@ export const ConversationListItem = ({
|
|
|
79
90
|
selected = false,
|
|
80
91
|
onClick,
|
|
81
92
|
onSelect,
|
|
93
|
+
showDivider = true,
|
|
94
|
+
highlightActive = true,
|
|
82
95
|
}: ConversationListItemProps) => {
|
|
83
96
|
const isActive = active || selected
|
|
84
97
|
const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
|
|
85
98
|
const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
|
|
86
|
-
|
|
99
|
+
// `contactId` é o identificador neutro; `whatsappNumber` fica como fallback enquanto o backend
|
|
100
|
+
// não informa canal.
|
|
101
|
+
const handle = conversation.contactId ?? conversation.whatsappNumber
|
|
102
|
+
const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
|
|
103
|
+
const name = conversation.clientName || displayHandle || handle
|
|
87
104
|
const timestamp = formatRelativeTime(conversation.lastAt)
|
|
105
|
+
const flag = contactFlag({ handle, channel: conversation.channel })
|
|
88
106
|
const isInbound = conversation.lastDirection === 'inbound'
|
|
89
107
|
|
|
90
108
|
const handleClick = () => {
|
|
@@ -95,13 +113,15 @@ export const ConversationListItem = ({
|
|
|
95
113
|
return (
|
|
96
114
|
<button
|
|
97
115
|
onClick={handleClick}
|
|
98
|
-
className=
|
|
116
|
+
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
117
|
style={{
|
|
100
|
-
backgroundColor: isActive ? '#f0f2f5' : 'transparent',
|
|
118
|
+
backgroundColor: isActive && highlightActive ? '#f0f2f5' : 'transparent',
|
|
101
119
|
}}
|
|
102
120
|
>
|
|
103
121
|
<div className="relative flex-shrink-0">
|
|
104
|
-
|
|
122
|
+
{/* Só o nome do cliente: passar o telefone faria o avatar exibir dígitos soltos em vez da
|
|
123
|
+
silhueta de contato sem nome. */}
|
|
124
|
+
<Avatar name={conversation.clientName} size="lg" />
|
|
105
125
|
{conversation.waitingHuman && (
|
|
106
126
|
<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
127
|
)}
|
|
@@ -110,6 +130,8 @@ export const ConversationListItem = ({
|
|
|
110
130
|
<div className="flex-1 min-w-0">
|
|
111
131
|
<div className="flex items-center justify-between gap-2">
|
|
112
132
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
133
|
+
{/* Sem nome, o título já é o telefone — a bandeira vai nele. */}
|
|
134
|
+
{!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
|
|
113
135
|
<span className="text-[16px] text-[#111b21] truncate">{name}</span>
|
|
114
136
|
{windowStatus && windowStatus.label === 'expired' && (
|
|
115
137
|
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
|
|
@@ -122,6 +144,15 @@ export const ConversationListItem = ({
|
|
|
122
144
|
<span className="text-xs text-[#667781] flex-shrink-0">{timestamp}</span>
|
|
123
145
|
)}
|
|
124
146
|
</div>
|
|
147
|
+
{/* Telefone só como subtítulo quando o título é o nome — repetir o número embaixo dele
|
|
148
|
+
mesmo gastaria uma linha para dizer duas vezes a mesma coisa. */}
|
|
149
|
+
{conversation.clientName ? (
|
|
150
|
+
<div className="flex items-center gap-1 text-xs text-[#667781]">
|
|
151
|
+
{flag ? <span aria-hidden>{flag}</span> : null}
|
|
152
|
+
<span className="truncate">{displayHandle}</span>
|
|
153
|
+
</div>
|
|
154
|
+
) : null}
|
|
155
|
+
|
|
125
156
|
<div className="flex items-center justify-between mt-0.5">
|
|
126
157
|
<span className="text-sm text-[#667781] truncate max-w-[180px]">
|
|
127
158
|
{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>
|