@adatechnology/conversations-ui 0.1.0-rc.5 → 0.1.0-rc.7
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-TGTBMMFC.js +1707 -0
- package/dist/index.d.ts +209 -22
- package/dist/index.js +243 -590
- package/dist/preview/index.d.ts +153 -9
- package/dist/preview/index.js +520 -38
- package/dist/{types-C2Yexi8A.d.ts → types-B5C1DLu1.d.ts} +70 -2
- package/package.json +2 -2
- package/src/Avatar.tsx +13 -2
- package/src/ConversationHeader.tsx +18 -0
- package/src/ConversationListItem.tsx +18 -2
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +46 -1
- package/src/FileIcon.tsx +76 -10
- package/src/InteractiveMessage.tsx +126 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MessageBubble.tsx +31 -4
- package/src/MessageComposer.tsx +36 -5
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/index.ts +25 -12
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/preview/AudioRecorderButton.tsx +117 -0
- package/src/preview/ConversationPreview.tsx +184 -15
- 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 +62 -8
- package/src/preview/createPreviewWebhookClient.ts +28 -1
- package/src/preview/index.ts +16 -2
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +140 -8
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/providers/types.ts +17 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/types.ts +38 -1
- package/dist/chunk-YWITIIHD.js +0 -728
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
1
3
|
interface ConversationsUIConfig {
|
|
2
4
|
apiBaseUrl: string;
|
|
3
5
|
theme?: ConversationsTheme;
|
|
@@ -17,9 +19,50 @@ interface ConversationsFeatures {
|
|
|
17
19
|
emoji?: boolean;
|
|
18
20
|
darkMode?: boolean;
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Recorte do bloco `interactive` da Meta que a UI precisa para desenhar o menu. Fica solto (e não
|
|
24
|
+
* espelhando o contrato inteiro) porque o que chega do banco é o payload cru já enviado ao
|
|
25
|
+
* WhatsApp: qualquer campo que a UI não conheça é ignorado, nunca causa erro de render.
|
|
26
|
+
*/
|
|
27
|
+
interface InteractiveOption {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
}
|
|
32
|
+
interface InteractiveSection {
|
|
33
|
+
title?: string;
|
|
34
|
+
rows?: InteractiveOption[];
|
|
35
|
+
}
|
|
36
|
+
interface InteractivePayload {
|
|
37
|
+
type?: 'button' | 'list' | string;
|
|
38
|
+
header?: {
|
|
39
|
+
text?: string;
|
|
40
|
+
};
|
|
41
|
+
body?: {
|
|
42
|
+
text?: string;
|
|
43
|
+
};
|
|
44
|
+
footer?: {
|
|
45
|
+
text?: string;
|
|
46
|
+
};
|
|
47
|
+
action?: {
|
|
48
|
+
/** Rótulo do botão que abre a lista — só existe em `type: 'list'`. */
|
|
49
|
+
button?: string;
|
|
50
|
+
sections?: InteractiveSection[];
|
|
51
|
+
buttons?: {
|
|
52
|
+
reply?: InteractiveOption;
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Como o cliente respondeu a um menu: por botão ou por item de lista. */
|
|
57
|
+
type InteractiveSelection = {
|
|
58
|
+
readonly kind: 'button' | 'list';
|
|
59
|
+
readonly option: InteractiveOption;
|
|
60
|
+
};
|
|
20
61
|
interface MessagePayload {
|
|
21
62
|
id: string;
|
|
22
|
-
type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template';
|
|
63
|
+
type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template' | 'interactive';
|
|
64
|
+
/** Payload cru da mensagem. Em `type: 'interactive'`, carrega o menu que o cliente vê. */
|
|
65
|
+
payload?: InteractivePayload | null;
|
|
23
66
|
content?: string;
|
|
24
67
|
caption?: string;
|
|
25
68
|
mediaUrl?: string;
|
|
@@ -51,6 +94,16 @@ interface MessagePayload {
|
|
|
51
94
|
isLastInGroup?: boolean;
|
|
52
95
|
}
|
|
53
96
|
|
|
97
|
+
type ResolveMediaUrl = (message: MessagePayload) => Promise<string | null>;
|
|
98
|
+
interface MediaRendererProps {
|
|
99
|
+
message: MessagePayload;
|
|
100
|
+
onLightbox: (src: string) => void;
|
|
101
|
+
onResolveUrl?: ResolveMediaUrl;
|
|
102
|
+
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
103
|
+
className?: string;
|
|
104
|
+
}
|
|
105
|
+
declare function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps): react.JSX.Element | null;
|
|
106
|
+
|
|
54
107
|
/**
|
|
55
108
|
* Canal de origem da conversa e o que cada um permite.
|
|
56
109
|
*
|
|
@@ -154,6 +207,14 @@ interface ListDocumentsParams {
|
|
|
154
207
|
source?: string;
|
|
155
208
|
sortDirection?: 'asc' | 'desc';
|
|
156
209
|
}
|
|
210
|
+
/** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
|
|
211
|
+
interface CompanyDocument extends ConversationDocument {
|
|
212
|
+
conversationId: string;
|
|
213
|
+
}
|
|
214
|
+
interface CompanyDocumentPage {
|
|
215
|
+
documents: CompanyDocument[];
|
|
216
|
+
total: number;
|
|
217
|
+
}
|
|
157
218
|
interface ConversationDocumentPage {
|
|
158
219
|
documents: ConversationDocument[];
|
|
159
220
|
total: number;
|
|
@@ -212,6 +273,13 @@ interface ConversationsApi {
|
|
|
212
273
|
* lote em vez de oferecer um botão que falha.
|
|
213
274
|
*/
|
|
214
275
|
downloadDocumentsArchive?(conversationId: string, uploadIds: readonly string[]): Promise<Blob>;
|
|
276
|
+
/**
|
|
277
|
+
* Biblioteca de TODAS as conversas, para uma tela de Documentos fora do atendimento.
|
|
278
|
+
*
|
|
279
|
+
* Opcional por capacidade: host que só expõe anexo dentro da conversa não implementa, e o
|
|
280
|
+
* componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
|
|
281
|
+
*/
|
|
282
|
+
getAllDocuments?(params?: ListDocumentsParams): Promise<CompanyDocumentPage>;
|
|
215
283
|
getMediaProxyUrl(mediaId: string): Promise<{
|
|
216
284
|
mimeType: string;
|
|
217
285
|
data: string;
|
|
@@ -294,4 +362,4 @@ interface ConversationDocument {
|
|
|
294
362
|
linkedAt: string;
|
|
295
363
|
}
|
|
296
364
|
|
|
297
|
-
export { CHANNEL_CAPABILITIES as C, DEFAULT_CONVERSATION_CHANNEL as D, type FormatContactHandleParams as F, HANDLE_KIND as H, type
|
|
365
|
+
export { type ResolveMediaUrl as A, capabilitiesOf as B, CHANNEL_CAPABILITIES as C, DEFAULT_CONVERSATION_CHANNEL as D, channelFiltersFor as E, type FormatContactHandleParams as F, contactFlag as G, HANDLE_KIND as H, type InteractiveOption as I, formatContactHandle as J, type ListConversationsParams as L, MediaRenderer as M, REOPEN_MECHANISM as R, type SSEProvider as S, CHANNEL_FILTER_ALL as a, CONVERSATION_CHANNEL as b, type ChannelCapabilities as c, type ChannelFilter as d, type ChannelFilterOption as e, type CompanyDocument as f, type CompanyDocumentPage as g, type ConversationChannel as h, type ConversationDocument as i, type ConversationDocumentPage as j, type ConversationEventSource as k, type ConversationPage as l, type ConversationSummary as m, type ConversationTemplate as n, type ConversationsApi as o, type ConversationsFeatures as p, type ConversationsTheme as q, type ConversationsUIConfig as r, type HandleKind as s, type InteractivePayload as t, type InteractiveSection as u, type InteractiveSelection as v, type ListDocumentsParams as w, type MediaRendererProps as x, type MessagePayload as y, type ReopenMechanism as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.7",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"lucide-react": "^1.21.0",
|
|
33
33
|
"tailwind-merge": "^3.6.0",
|
|
34
|
-
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.
|
|
34
|
+
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.6"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^18 || ^19",
|
package/src/Avatar.tsx
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
export interface AvatarLabels {
|
|
2
|
+
/** Lido por leitor de tela quando não há nome nem imagem — a silhueta genérica. */
|
|
3
|
+
unnamedContact: string
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
export interface AvatarProps {
|
|
2
7
|
name?: string | null
|
|
3
8
|
avatarUrl?: string
|
|
4
9
|
size?: 'sm' | 'md' | 'lg'
|
|
5
10
|
className?: string
|
|
11
|
+
labels?: Partial<AvatarLabels>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_AVATAR_LABELS: AvatarLabels = {
|
|
15
|
+
unnamedContact: 'Contato sem nome',
|
|
6
16
|
}
|
|
7
17
|
|
|
8
18
|
const sizeClasses = {
|
|
@@ -36,8 +46,9 @@ function getBgColor(name: string | undefined | null): string {
|
|
|
36
46
|
|
|
37
47
|
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/Avatar.tsx —
|
|
38
48
|
// mesmas iniciais (primeira+última letra), mesmo hash de cor, mesmas classes de tamanho.
|
|
39
|
-
export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarProps) {
|
|
49
|
+
export function Avatar({ name, avatarUrl, size = 'md', className = '', labels }: AvatarProps) {
|
|
40
50
|
const sizeClass = sizeClasses[size]
|
|
51
|
+
const unnamedContactLabel = labels?.unnamedContact ?? DEFAULT_AVATAR_LABELS.unnamedContact
|
|
41
52
|
|
|
42
53
|
if (avatarUrl) {
|
|
43
54
|
return (
|
|
@@ -58,7 +69,7 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarP
|
|
|
58
69
|
<div
|
|
59
70
|
className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white flex-shrink-0 ${className}`}
|
|
60
71
|
role="img"
|
|
61
|
-
aria-label=
|
|
72
|
+
aria-label={unnamedContactLabel}
|
|
62
73
|
>
|
|
63
74
|
<svg viewBox="0 0 24 24" fill="currentColor" className="h-[60%] w-[60%]" aria-hidden>
|
|
64
75
|
<path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5Z" />
|
|
@@ -52,6 +52,20 @@ export interface ConversationHeaderClassNames {
|
|
|
52
52
|
mobileMenu: string
|
|
53
53
|
}
|
|
54
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
|
+
|
|
55
69
|
export interface ConversationHeaderProps {
|
|
56
70
|
conversation: ConversationSummary
|
|
57
71
|
busy?: boolean
|
|
@@ -61,6 +75,8 @@ export interface ConversationHeaderProps {
|
|
|
61
75
|
onDownload?: () => void
|
|
62
76
|
onOpenDocuments?: () => void
|
|
63
77
|
documentsOpen?: boolean
|
|
78
|
+
/** Ações do produto que não existem no contrato do pacote (ex.: ferramentas de dev). */
|
|
79
|
+
extraUtilities?: readonly ConversationHeaderUtility[]
|
|
64
80
|
onBack?: () => void
|
|
65
81
|
labels?: Partial<ConversationHeaderLabels>
|
|
66
82
|
className?: string
|
|
@@ -76,6 +92,7 @@ export function ConversationHeader({
|
|
|
76
92
|
onDownload,
|
|
77
93
|
onOpenDocuments,
|
|
78
94
|
documentsOpen = false,
|
|
95
|
+
extraUtilities,
|
|
79
96
|
onBack,
|
|
80
97
|
labels: labelsOverride,
|
|
81
98
|
className,
|
|
@@ -96,6 +113,7 @@ export function ConversationHeader({
|
|
|
96
113
|
? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
|
|
97
114
|
: undefined,
|
|
98
115
|
onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
|
|
116
|
+
...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
|
|
99
117
|
].filter(
|
|
100
118
|
(utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
|
|
101
119
|
Boolean(utility),
|
|
@@ -3,8 +3,21 @@ import { Avatar } from './Avatar'
|
|
|
3
3
|
import { contactFlag, formatContactHandle } from './conversationChannel'
|
|
4
4
|
import type { ConversationSummary } from './providers/types'
|
|
5
5
|
|
|
6
|
+
export interface ConversationListItemLabels {
|
|
7
|
+
/** Tooltip do ponto vermelho: a janela de atendimento de 24h já fechou. */
|
|
8
|
+
expiredWindow: string
|
|
9
|
+
/** Tooltip do ponto laranja: a janela de atendimento está perto de fechar. */
|
|
10
|
+
warningWindow: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_CONVERSATION_LIST_ITEM_LABELS: ConversationListItemLabels = {
|
|
14
|
+
expiredWindow: 'Janela expirada',
|
|
15
|
+
warningWindow: 'Janela próxima do fim',
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export interface ConversationListItemProps {
|
|
7
19
|
conversation: ConversationSummary
|
|
20
|
+
labels?: Partial<ConversationListItemLabels>
|
|
8
21
|
active?: boolean
|
|
9
22
|
selected?: boolean
|
|
10
23
|
onClick?: () => void
|
|
@@ -92,7 +105,10 @@ export const ConversationListItem = ({
|
|
|
92
105
|
onSelect,
|
|
93
106
|
showDivider = true,
|
|
94
107
|
highlightActive = true,
|
|
108
|
+
labels,
|
|
95
109
|
}: ConversationListItemProps) => {
|
|
110
|
+
const expiredWindowLabel = labels?.expiredWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.expiredWindow
|
|
111
|
+
const warningWindowLabel = labels?.warningWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.warningWindow
|
|
96
112
|
const isActive = active || selected
|
|
97
113
|
const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
|
|
98
114
|
const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
|
|
@@ -134,10 +150,10 @@ export const ConversationListItem = ({
|
|
|
134
150
|
{!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
|
|
135
151
|
<span className="text-[16px] text-[#111b21] truncate">{name}</span>
|
|
136
152
|
{windowStatus && windowStatus.label === 'expired' && (
|
|
137
|
-
<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} />
|
|
138
154
|
)}
|
|
139
155
|
{windowStatus && windowStatus.label === 'warning' && (
|
|
140
|
-
<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} />
|
|
141
157
|
)}
|
|
142
158
|
</div>
|
|
143
159
|
{timestamp && (
|
|
@@ -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
|
+
}
|