@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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marca visual de cada canal.
|
|
3
|
+
*
|
|
4
|
+
* SVG em vez de emoji porque emoji não tem logo de marca: 💬 e 📨 não distinguem WhatsApp de
|
|
5
|
+
* Messenger para quem bate o olho numa fila de conversas. São marcas **simplificadas** desenhadas
|
|
6
|
+
* com primitivas — reconhecíveis pela forma e pela cor oficial, sem reproduzir o logotipo original.
|
|
7
|
+
*
|
|
8
|
+
* `currentColor` não serve aqui: a cor faz parte da identificação do canal.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CONVERSATION_CHANNEL, capabilitiesOf, type ConversationChannel } from './conversationChannel'
|
|
12
|
+
|
|
13
|
+
export const CHANNEL_BRAND_COLOR: Readonly<Record<ConversationChannel, string>> = {
|
|
14
|
+
[CONVERSATION_CHANNEL.WHATSAPP]: '#25D366',
|
|
15
|
+
[CONVERSATION_CHANNEL.MESSENGER]: '#0084FF',
|
|
16
|
+
[CONVERSATION_CHANNEL.INSTAGRAM]: '#E4405F',
|
|
17
|
+
[CONVERSATION_CHANNEL.WEBCHAT]: '#64748B',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChannelIconProps {
|
|
21
|
+
channel?: ConversationChannel | undefined
|
|
22
|
+
size?: number
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Glyph({ channel }: { channel: ConversationChannel }) {
|
|
27
|
+
const color = CHANNEL_BRAND_COLOR[channel]
|
|
28
|
+
|
|
29
|
+
if (channel === CONVERSATION_CHANNEL.WHATSAPP) {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<circle cx="12" cy="12" r="11" fill={color} />
|
|
33
|
+
{/* Handset estilizado, o traço que identifica a marca à distância. */}
|
|
34
|
+
<path
|
|
35
|
+
d="M8.6 7.2c.3-.1.7 0 .9.3l1 1.6c.2.3.1.7-.1.9l-.7.7c.6 1.3 1.6 2.3 2.9 2.9l.7-.7c.2-.2.6-.3.9-.1l1.6 1c.3.2.4.6.3.9-.4 1-1.4 1.6-2.4 1.4-3-.5-5.4-2.9-5.9-5.9-.2-1 .4-2 1.4-2.4Z"
|
|
36
|
+
fill="#fff"
|
|
37
|
+
/>
|
|
38
|
+
</>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (channel === CONVERSATION_CHANNEL.MESSENGER) {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<circle cx="12" cy="12" r="11" fill={color} />
|
|
46
|
+
{/* Raio do Messenger. */}
|
|
47
|
+
<path d="M6.5 15.5 11 10.8l2.4 2.4L17.5 9l-4.6 4.9-2.4-2.4-4 4Z" fill="#fff" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
|
|
48
|
+
</>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (channel === CONVERSATION_CHANNEL.INSTAGRAM) {
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<rect x="2" y="2" width="20" height="20" rx="6" fill={color} />
|
|
56
|
+
<rect x="6.5" y="6.5" width="11" height="11" rx="3.5" fill="none" stroke="#fff" strokeWidth="1.6" />
|
|
57
|
+
<circle cx="12" cy="12" r="2.6" fill="none" stroke="#fff" strokeWidth="1.6" />
|
|
58
|
+
<circle cx="16.4" cy="7.6" r="1" fill="#fff" />
|
|
59
|
+
</>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<circle cx="12" cy="12" r="11" fill={color} />
|
|
66
|
+
<path d="M6.5 8.5h11v7h-5.5L8.5 18v-2.5h-2v-7Z" fill="#fff" />
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ChannelIcon({ channel, size = 14, className = '' }: ChannelIconProps) {
|
|
72
|
+
const resolved: ConversationChannel = channel ?? CONVERSATION_CHANNEL.WHATSAPP
|
|
73
|
+
const { label } = capabilitiesOf(resolved)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<svg
|
|
77
|
+
viewBox="0 0 24 24"
|
|
78
|
+
width={size}
|
|
79
|
+
height={size}
|
|
80
|
+
className={`inline-block shrink-0 ${className}`.trim()}
|
|
81
|
+
role="img"
|
|
82
|
+
aria-label={label}
|
|
83
|
+
>
|
|
84
|
+
<Glyph channel={resolved} />
|
|
85
|
+
</svg>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Suas Seleções": o que o bot já coletou na conversa. Para o atendente que assume no meio, é a
|
|
3
|
+
* diferença entre ler o transcript inteiro e ver o estado em duas linhas.
|
|
4
|
+
*
|
|
5
|
+
* O pacote não interpreta o contexto — ele é `Record<string, unknown>` e cada produto nomeia as
|
|
6
|
+
* próprias chaves. O host traduz para `entries`; aqui só se decide como mostrar.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import { cn } from './lib/cn'
|
|
12
|
+
import { useIsNarrow } from './useIsNarrow'
|
|
13
|
+
|
|
14
|
+
export interface ConversationContextEntry {
|
|
15
|
+
key: string
|
|
16
|
+
label: string
|
|
17
|
+
value?: string | undefined
|
|
18
|
+
icon?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ConversationContextPanelLabels {
|
|
22
|
+
title: string
|
|
23
|
+
empty: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
|
|
27
|
+
title: '📋 Suas Seleções',
|
|
28
|
+
empty: 'Nada coletado ainda nesta conversa.',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ConversationContextPanelClassNames {
|
|
32
|
+
root: string
|
|
33
|
+
toggle: string
|
|
34
|
+
counter: string
|
|
35
|
+
body: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ConversationContextPanelProps {
|
|
39
|
+
entries: readonly ConversationContextEntry[]
|
|
40
|
+
labels?: Partial<ConversationContextPanelLabels>
|
|
41
|
+
className?: string
|
|
42
|
+
classNames?: Partial<ConversationContextPanelClassNames>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ConversationContextPanel({
|
|
46
|
+
entries,
|
|
47
|
+
labels: labelsOverride,
|
|
48
|
+
className,
|
|
49
|
+
classNames,
|
|
50
|
+
}: ConversationContextPanelProps) {
|
|
51
|
+
const labels = { ...DEFAULT_CONVERSATION_CONTEXT_LABELS, ...labelsOverride }
|
|
52
|
+
const filled = entries.filter((entry) => Boolean(entry.value))
|
|
53
|
+
|
|
54
|
+
// Abre sozinho só quando há algo coletado: um painel de seis traços ocupa a altura da conversa
|
|
55
|
+
// sem informar nada. O contador continua visível fechado, que é o dado útil de relance.
|
|
56
|
+
//
|
|
57
|
+
// `undefined` = usuário ainda não mexeu. Guardar só um booleano não serve: o contexto chega
|
|
58
|
+
// depois da montagem, então o estado inicial seria calculado com a lista vazia e o painel ficaria
|
|
59
|
+
// fechado mesmo com dados.
|
|
60
|
+
const isNarrow = useIsNarrow()
|
|
61
|
+
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
|
62
|
+
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa. O
|
|
63
|
+
// contador no cabeçalho já entrega a informação de relance.
|
|
64
|
+
const open = manualOpen ?? (!isNarrow && filled.length > 0)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<section className={cn('border-b', classNames?.root, className)}>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => setManualOpen(!open)}
|
|
71
|
+
aria-expanded={open}
|
|
72
|
+
className={cn('flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium', classNames?.toggle)}
|
|
73
|
+
>
|
|
74
|
+
<span aria-hidden className="text-xs">
|
|
75
|
+
{open ? '▾' : '▸'}
|
|
76
|
+
</span>
|
|
77
|
+
<span>{labels.title}</span>
|
|
78
|
+
<span className={cn('rounded-full bg-gray-200 px-2 text-xs dark:bg-gray-700', classNames?.counter)}>
|
|
79
|
+
{filled.length}/{entries.length}
|
|
80
|
+
</span>
|
|
81
|
+
</button>
|
|
82
|
+
|
|
83
|
+
{open ? (
|
|
84
|
+
<div className={cn('px-4 pb-3', classNames?.body)}>
|
|
85
|
+
{entries.length === 0 ? (
|
|
86
|
+
<p className="text-xs text-gray-500">{labels.empty}</p>
|
|
87
|
+
) : (
|
|
88
|
+
<dl className="grid grid-cols-2 gap-2 text-xs">
|
|
89
|
+
{entries.map((entry) => (
|
|
90
|
+
<div key={entry.key} className="min-w-0">
|
|
91
|
+
<dt className="truncate text-gray-500">
|
|
92
|
+
{entry.icon ? `${entry.icon} ` : ''}
|
|
93
|
+
{entry.label}
|
|
94
|
+
</dt>
|
|
95
|
+
<dd className="truncate font-medium">
|
|
96
|
+
{entry.value ? <span className="text-green-700 dark:text-green-400">✓ {entry.value}</span> : '—'}
|
|
97
|
+
</dd>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</dl>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
</section>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
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 { ArrowUpDown, Bot, Download, Eye, Users } from 'lucide-react'
|
|
12
|
+
import { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
13
|
+
import { useConversations } from './providers/ConversationsProvider'
|
|
14
|
+
import { FileIcon } from './FileIcon'
|
|
15
|
+
import { cn } from './lib/cn'
|
|
16
|
+
import { formatDateTime, formatFileSize } from './lib/format'
|
|
17
|
+
|
|
18
|
+
/** Origens que o filtro oferece. `all` não é origem — é a ausência de filtro. */
|
|
19
|
+
export const DOCUMENT_SOURCE_FILTER = {
|
|
20
|
+
ALL: 'all',
|
|
21
|
+
CUSTOMER: 'customer',
|
|
22
|
+
TEAM: 'team',
|
|
23
|
+
} as const
|
|
24
|
+
export type DocumentSourceFilter = (typeof DOCUMENT_SOURCE_FILTER)[keyof typeof DOCUMENT_SOURCE_FILTER]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `bot` e `agent` caem em "Equipe": para quem procura um comprovante, o que importa é se veio do
|
|
28
|
+
* cliente ou saiu da loja — não se foi o robô ou a pessoa que apertou enviar.
|
|
29
|
+
*/
|
|
30
|
+
const TEAM_SOURCES = new Set(['agent', 'bot'])
|
|
31
|
+
|
|
32
|
+
export interface ConversationDocumentsPanelLabels {
|
|
33
|
+
toggle: string
|
|
34
|
+
title: string
|
|
35
|
+
searchPlaceholder: string
|
|
36
|
+
empty: string
|
|
37
|
+
/** Distinto de `empty`: sem resultado POR CAUSA do filtro, e não conversa sem anexo nenhum. */
|
|
38
|
+
noResults: string
|
|
39
|
+
loading: string
|
|
40
|
+
failure: string
|
|
41
|
+
download: string
|
|
42
|
+
view: string
|
|
43
|
+
sourceFilterAll: string
|
|
44
|
+
sourceFilterCustomer: string
|
|
45
|
+
sourceFilterTeam: string
|
|
46
|
+
sortMostRecent: string
|
|
47
|
+
sortOldest: string
|
|
48
|
+
clearFilters: string
|
|
49
|
+
selectAll: string
|
|
50
|
+
downloadSelected: (count: number) => string
|
|
51
|
+
archiveFailed: string
|
|
52
|
+
total: (count: number) => string
|
|
53
|
+
page: (current: number, last: number) => string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_CONVERSATION_DOCUMENTS_LABELS: ConversationDocumentsPanelLabels = {
|
|
57
|
+
toggle: '📎 Arquivos',
|
|
58
|
+
title: 'Arquivos da conversa',
|
|
59
|
+
noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
|
|
60
|
+
view: 'Visualizar',
|
|
61
|
+
sourceFilterAll: 'Todas as origens',
|
|
62
|
+
sourceFilterCustomer: 'Cliente',
|
|
63
|
+
sourceFilterTeam: 'Equipe',
|
|
64
|
+
sortMostRecent: 'Mais recentes',
|
|
65
|
+
sortOldest: 'Mais antigos',
|
|
66
|
+
clearFilters: 'Limpar filtros',
|
|
67
|
+
selectAll: 'Selecionar todos desta página',
|
|
68
|
+
downloadSelected: (count: number) => `Baixar ${count} selecionado${count === 1 ? '' : 's'} (.zip)`,
|
|
69
|
+
archiveFailed: 'Não foi possível montar o arquivo compactado.',
|
|
70
|
+
total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
|
|
71
|
+
page: (current: number, last: number) => `${current} / ${last}`,
|
|
72
|
+
searchPlaceholder: 'Buscar por nome do arquivo',
|
|
73
|
+
empty: 'Nenhum arquivo nesta conversa.',
|
|
74
|
+
loading: 'Carregando arquivos…',
|
|
75
|
+
failure: 'Não foi possível carregar os arquivos.',
|
|
76
|
+
download: 'Baixar',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Partes estilizáveis do painel, no mesmo contrato do `ConversationHeader`: `cn` funde por cima da
|
|
81
|
+
* base e conflito de utilitário (padding, tamanho de fonte, borda) fica com o valor do produto.
|
|
82
|
+
*
|
|
83
|
+
* `status` cobre carregando/erro/vazio de uma vez — são a mesma linha de texto auxiliar, e slots
|
|
84
|
+
* separados só multiplicariam chave para quem quer mudar a cor de aviso.
|
|
85
|
+
*/
|
|
86
|
+
export interface ConversationDocumentsPanelClassNames {
|
|
87
|
+
root: string
|
|
88
|
+
body: string
|
|
89
|
+
title: string
|
|
90
|
+
filters: string
|
|
91
|
+
search: string
|
|
92
|
+
sourceSelect: string
|
|
93
|
+
sortButton: string
|
|
94
|
+
clearButton: string
|
|
95
|
+
status: string
|
|
96
|
+
list: string
|
|
97
|
+
item: string
|
|
98
|
+
sourceBadge: string
|
|
99
|
+
filename: string
|
|
100
|
+
meta: string
|
|
101
|
+
viewButton: string
|
|
102
|
+
downloadButton: string
|
|
103
|
+
pagination: string
|
|
104
|
+
selectionBar: string
|
|
105
|
+
checkbox: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ConversationDocumentsPanelProps {
|
|
109
|
+
conversationId: string
|
|
110
|
+
/** Controlado de fora porque o gatilho vive no cabeçalho, junto das outras ações da conversa. */
|
|
111
|
+
open: boolean
|
|
112
|
+
/** Itens por página. O total vem do servidor; sem paginação no host, a barra não aparece. */
|
|
113
|
+
perPage?: number
|
|
114
|
+
labels?: Partial<ConversationDocumentsPanelLabels>
|
|
115
|
+
className?: string
|
|
116
|
+
classNames?: Partial<ConversationDocumentsPanelClassNames>
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_PER_PAGE = 10
|
|
120
|
+
|
|
121
|
+
export function ConversationDocumentsPanel({
|
|
122
|
+
conversationId,
|
|
123
|
+
open,
|
|
124
|
+
perPage = DEFAULT_PER_PAGE,
|
|
125
|
+
labels: labelsOverride,
|
|
126
|
+
className,
|
|
127
|
+
classNames,
|
|
128
|
+
}: ConversationDocumentsPanelProps) {
|
|
129
|
+
const labels = { ...DEFAULT_CONVERSATION_DOCUMENTS_LABELS, ...labelsOverride }
|
|
130
|
+
const context = useConversations()
|
|
131
|
+
const [search, setSearch] = useState('')
|
|
132
|
+
const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
|
|
133
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
|
134
|
+
const [page, setPage] = useState(1)
|
|
135
|
+
const [selectedIds, setSelectedIds] = useState<readonly string[]>([])
|
|
136
|
+
const [archiveError, setArchiveError] = useState(false)
|
|
137
|
+
|
|
138
|
+
const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
|
|
139
|
+
|
|
140
|
+
// Só busca quando o painel abre: a lista de anexos é consulta extra e não deve pesar em toda
|
|
141
|
+
// conversa aberta.
|
|
142
|
+
const { documents, total, loading, error } = useConversationDocuments(open ? conversationId : undefined, {
|
|
143
|
+
search,
|
|
144
|
+
page,
|
|
145
|
+
limit: perPage,
|
|
146
|
+
sortDirection,
|
|
147
|
+
...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Toda mudança de filtro volta para a página 1. Sem isso, filtrar na página 3 pede ao servidor
|
|
154
|
+
* uma fatia que o novo resultado talvez não tenha, e o painel aparece vazio como se não houvesse
|
|
155
|
+
* arquivo — o mesmo motivo do `setDocumentsPage(1)` em cada handler do financiamento.
|
|
156
|
+
*/
|
|
157
|
+
function applyFilter(change: () => void): void {
|
|
158
|
+
change()
|
|
159
|
+
setPage(1)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
|
|
163
|
+
const url = await context?.api.getDocumentUrl(uploadId, disposition)
|
|
164
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Ausente = host não sabe montar zip (só assina URL). Esconder é melhor que oferecer e falhar.
|
|
168
|
+
const canArchive = typeof context?.api.downloadDocumentsArchive === 'function'
|
|
169
|
+
const pageIds = documents.map((document) => document.id)
|
|
170
|
+
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.includes(id))
|
|
171
|
+
|
|
172
|
+
function toggleSelected(uploadId: string): void {
|
|
173
|
+
setArchiveError(false)
|
|
174
|
+
setSelectedIds((current) =>
|
|
175
|
+
current.includes(uploadId) ? current.filter((id) => id !== uploadId) : [...current, uploadId],
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toggleAllOnPage(): void {
|
|
180
|
+
setArchiveError(false)
|
|
181
|
+
// Só mexe nos itens DESTA página: quem selecionou algo na página 1, foi para a 2 e marcou
|
|
182
|
+
// "todos" não deve perder a seleção anterior.
|
|
183
|
+
setSelectedIds((current) =>
|
|
184
|
+
allOnPageSelected
|
|
185
|
+
? current.filter((id) => !pageIds.includes(id))
|
|
186
|
+
: [...current, ...pageIds.filter((id) => !current.includes(id))],
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function handleDownloadSelected(): Promise<void> {
|
|
191
|
+
const archive = context?.api.downloadDocumentsArchive
|
|
192
|
+
if (!archive || selectedIds.length === 0) return
|
|
193
|
+
|
|
194
|
+
setArchiveError(false)
|
|
195
|
+
try {
|
|
196
|
+
const blob = await archive(conversationId, selectedIds)
|
|
197
|
+
// Âncora temporária com objectURL: é o que faz o navegador salvar um Blob com nome. O
|
|
198
|
+
// revoke é obrigatório — sem ele o blob fica na memória da aba até recarregar.
|
|
199
|
+
const url = URL.createObjectURL(blob)
|
|
200
|
+
const anchor = document.createElement('a')
|
|
201
|
+
anchor.href = url
|
|
202
|
+
anchor.download = `conversa-${conversationId}.zip`
|
|
203
|
+
anchor.click()
|
|
204
|
+
URL.revokeObjectURL(url)
|
|
205
|
+
setSelectedIds([])
|
|
206
|
+
} catch {
|
|
207
|
+
setArchiveError(true)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!open) return null
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className={cn('border-b', classNames?.root, className)}>
|
|
215
|
+
<section className={cn('px-4 py-3', classNames?.body)}>
|
|
216
|
+
<p className={cn('mb-2 text-sm font-medium', classNames?.title)}>{labels.title}</p>
|
|
217
|
+
|
|
218
|
+
{/* Barra de filtros com wrap: em tela estreita os quatro controles empilham em vez de
|
|
219
|
+
estourar a largura do painel. */}
|
|
220
|
+
<div className={cn('mb-2 flex flex-wrap items-center gap-2 border-b pb-2', classNames?.filters)}>
|
|
221
|
+
<input
|
|
222
|
+
type="search"
|
|
223
|
+
value={search}
|
|
224
|
+
onChange={(event) => applyFilter(() => setSearch(event.target.value))}
|
|
225
|
+
placeholder={labels.searchPlaceholder}
|
|
226
|
+
aria-label={labels.searchPlaceholder}
|
|
227
|
+
className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-52', classNames?.search)}
|
|
228
|
+
/>
|
|
229
|
+
|
|
230
|
+
<select
|
|
231
|
+
value={sourceFilter}
|
|
232
|
+
onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
|
|
233
|
+
aria-label={labels.sourceFilterAll}
|
|
234
|
+
className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-36', classNames?.sourceSelect)}
|
|
235
|
+
>
|
|
236
|
+
<option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
|
|
237
|
+
<option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
|
|
238
|
+
<option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
|
|
239
|
+
</select>
|
|
240
|
+
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
|
|
244
|
+
className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
|
|
245
|
+
>
|
|
246
|
+
<ArrowUpDown size={14} />
|
|
247
|
+
{sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
|
|
248
|
+
</button>
|
|
249
|
+
|
|
250
|
+
{/* Só aparece com filtro ativo: botão que não faz nada visível é ruído. */}
|
|
251
|
+
{hasFilters ? (
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
onClick={() =>
|
|
255
|
+
applyFilter(() => {
|
|
256
|
+
setSearch('')
|
|
257
|
+
setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
|
|
258
|
+
setSortDirection('desc')
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
className={cn('cv-header-action', classNames?.clearButton)}
|
|
262
|
+
>
|
|
263
|
+
{labels.clearFilters}
|
|
264
|
+
</button>
|
|
265
|
+
) : null}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{loading ? <p className={cn('text-xs text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
|
|
269
|
+
{error ? (
|
|
270
|
+
<p role="alert" className={cn('text-xs text-red-600 dark:text-red-400', classNames?.status)}>
|
|
271
|
+
{labels.failure}
|
|
272
|
+
</p>
|
|
273
|
+
) : null}
|
|
274
|
+
{!loading && !error && documents.length === 0 ? (
|
|
275
|
+
<p className={cn('text-xs text-gray-500', classNames?.status)}>
|
|
276
|
+
{/* Distinguir os dois evita o mal-entendido de "a conversa não tem anexo" quando o
|
|
277
|
+
filtro é que não casou. */}
|
|
278
|
+
{hasFilters ? labels.noResults : labels.empty}
|
|
279
|
+
</p>
|
|
280
|
+
) : null}
|
|
281
|
+
|
|
282
|
+
{canArchive && documents.length > 0 ? (
|
|
283
|
+
<div className={cn('mb-2 flex flex-wrap items-center gap-3 text-xs', classNames?.selectionBar)}>
|
|
284
|
+
<label className="inline-flex items-center gap-1.5">
|
|
285
|
+
<input
|
|
286
|
+
type="checkbox"
|
|
287
|
+
checked={allOnPageSelected}
|
|
288
|
+
onChange={toggleAllOnPage}
|
|
289
|
+
className={cn(classNames?.checkbox)}
|
|
290
|
+
/>
|
|
291
|
+
{labels.selectAll}
|
|
292
|
+
</label>
|
|
293
|
+
{selectedIds.length > 0 ? (
|
|
294
|
+
<button type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
|
|
295
|
+
{labels.downloadSelected(selectedIds.length)}
|
|
296
|
+
</button>
|
|
297
|
+
) : null}
|
|
298
|
+
{archiveError ? (
|
|
299
|
+
<span role="alert" className="text-red-600 dark:text-red-400">
|
|
300
|
+
{labels.archiveFailed}
|
|
301
|
+
</span>
|
|
302
|
+
) : null}
|
|
303
|
+
</div>
|
|
304
|
+
) : null}
|
|
305
|
+
|
|
306
|
+
<ul className={cn('space-y-2', classNames?.list)}>
|
|
307
|
+
{documents.map((document) => {
|
|
308
|
+
const isFromCustomer = !TEAM_SOURCES.has(document.source)
|
|
309
|
+
const SourceIcon = isFromCustomer ? Users : Bot
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<li
|
|
313
|
+
key={document.id}
|
|
314
|
+
className={cn(
|
|
315
|
+
'flex items-center justify-between gap-2 rounded-lg border px-3 py-2 dark:border-gray-700',
|
|
316
|
+
classNames?.item,
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
319
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
320
|
+
{canArchive ? (
|
|
321
|
+
<input
|
|
322
|
+
type="checkbox"
|
|
323
|
+
checked={selectedIds.includes(document.id)}
|
|
324
|
+
onChange={() => toggleSelected(document.id)}
|
|
325
|
+
aria-label={`${labels.download}: ${document.filename}`}
|
|
326
|
+
className={cn('shrink-0', classNames?.checkbox)}
|
|
327
|
+
/>
|
|
328
|
+
) : null}
|
|
329
|
+
{/* `filename` junto do mimeType, e não só o mimeType: o mapa de ícones é indexado
|
|
330
|
+
por extensão curta, e o mimeType do Office é longo
|
|
331
|
+
(`…wordprocessingml.document`), então docx/doc/xlsx/xls caíam todos no ícone
|
|
332
|
+
genérico cinza. Com o nome, a extensão resolve primeiro. */}
|
|
333
|
+
<FileIcon filename={document.filename} mimeType={document.mimeType} />
|
|
334
|
+
<div className="min-w-0 flex-1">
|
|
335
|
+
<div
|
|
336
|
+
className={cn(
|
|
337
|
+
'mb-0.5 flex items-center gap-1 text-[11px] font-medium',
|
|
338
|
+
isFromCustomer
|
|
339
|
+
? 'text-blue-600 dark:text-blue-400'
|
|
340
|
+
: 'text-emerald-600 dark:text-emerald-400',
|
|
341
|
+
classNames?.sourceBadge,
|
|
342
|
+
)}
|
|
343
|
+
>
|
|
344
|
+
<SourceIcon size={11} />
|
|
345
|
+
{isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
|
|
346
|
+
</div>
|
|
347
|
+
{/* `title` no nome: truncado, o atendente não tem outro jeito de ler inteiro. */}
|
|
348
|
+
<div
|
|
349
|
+
className={cn('truncate text-sm font-medium', classNames?.filename)}
|
|
350
|
+
title={document.filename}
|
|
351
|
+
>
|
|
352
|
+
{document.filename}
|
|
353
|
+
</div>
|
|
354
|
+
<div className={cn('text-xs text-gray-500 dark:text-gray-400', classNames?.meta)}>
|
|
355
|
+
{formatDateTime(document.linkedAt)}
|
|
356
|
+
{' · '}
|
|
357
|
+
{formatFileSize(document.sizeBytes)}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Ver e baixar são ações distintas, não a mesma com nome diferente: o
|
|
363
|
+
`disposition` viaja na assinatura da URL, e depois de assinada não há como o
|
|
364
|
+
cliente mudar entre abrir no navegador e salvar. */}
|
|
365
|
+
<div className="flex shrink-0 gap-1">
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
onClick={() => void handleOpen(document.id, 'inline')}
|
|
369
|
+
title={labels.view}
|
|
370
|
+
aria-label={`${labels.view}: ${document.filename}`}
|
|
371
|
+
className={cn('cv-header-icon', classNames?.viewButton)}
|
|
372
|
+
>
|
|
373
|
+
<Eye size={14} />
|
|
374
|
+
</button>
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={() => void handleOpen(document.id, 'attachment')}
|
|
378
|
+
title={labels.download}
|
|
379
|
+
aria-label={`${labels.download}: ${document.filename}`}
|
|
380
|
+
className={cn('cv-header-icon', classNames?.downloadButton)}
|
|
381
|
+
>
|
|
382
|
+
<Download size={14} />
|
|
383
|
+
</button>
|
|
384
|
+
</div>
|
|
385
|
+
</li>
|
|
386
|
+
)
|
|
387
|
+
})}
|
|
388
|
+
</ul>
|
|
389
|
+
|
|
390
|
+
{/* Só com mais de uma página: barra de paginação numa lista de três anexos é ruído. */}
|
|
391
|
+
{total > perPage ? (
|
|
392
|
+
<div
|
|
393
|
+
className={cn(
|
|
394
|
+
'mt-2 flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700',
|
|
395
|
+
classNames?.pagination,
|
|
396
|
+
)}
|
|
397
|
+
>
|
|
398
|
+
<span className="text-gray-400">{labels.total(total)}</span>
|
|
399
|
+
<div className="flex items-center gap-2">
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
onClick={() => setPage(page - 1)}
|
|
403
|
+
disabled={page <= 1}
|
|
404
|
+
aria-label={labels.sortOldest}
|
|
405
|
+
className="cv-header-icon disabled:opacity-40"
|
|
406
|
+
>
|
|
407
|
+
‹
|
|
408
|
+
</button>
|
|
409
|
+
<span className="text-gray-500">{labels.page(page, lastPage)}</span>
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
onClick={() => setPage(page + 1)}
|
|
413
|
+
disabled={page >= lastPage}
|
|
414
|
+
aria-label={labels.sortMostRecent}
|
|
415
|
+
className="cv-header-icon disabled:opacity-40"
|
|
416
|
+
>
|
|
417
|
+
›
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
) : null}
|
|
422
|
+
</section>
|
|
423
|
+
</div>
|
|
424
|
+
)
|
|
425
|
+
}
|