@adatechnology/conversations-ui 0.1.0-rc.4 β 0.1.0-rc.41
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/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js β chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2389 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -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 +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1219 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -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/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +225 -17
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Moon, Sun } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { cn } from './lib/cn'
|
|
4
|
+
import { ICON_SIZE_ACTION } from './icon.constant'
|
|
5
|
+
|
|
6
|
+
export interface DarkModeToggleLabels {
|
|
7
|
+
/** AΓ§Γ£o de ir para o tema escuro β Γ© o rΓ³tulo lido enquanto o tema claro estΓ‘ ativo. */
|
|
8
|
+
toDark: string
|
|
9
|
+
toLight: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DarkModeToggleClassNames {
|
|
13
|
+
/** O tamanho do Γcone vem daqui quando o host usa a prΓ³pria escala (`size-4`, `size-5`). */
|
|
14
|
+
icon?: string
|
|
15
|
+
label?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DarkModeToggleProps {
|
|
19
|
+
isDark: boolean
|
|
20
|
+
onToggle: () => void
|
|
21
|
+
/** RΓ³tulo ao lado do Γcone. SΓ³-Γcone exige a Γ‘rea de toque mΓnima, que a classe `cv-touch` dΓ‘. */
|
|
22
|
+
showLabel?: boolean
|
|
23
|
+
labels?: Partial<DarkModeToggleLabels>
|
|
24
|
+
/**
|
|
25
|
+
* Substitui a aparΓͺncia do botΓ£o. As classes passam por `tailwind-merge`, entΓ£o utilitΓ‘rio do
|
|
26
|
+
* host vence o do pacote em vez de somar: `px-4` descarta o `px-3` daqui.
|
|
27
|
+
*
|
|
28
|
+
* A exceΓ§Γ£o Γ© utilitΓ‘rio de token do host (`rounded-panel`), que o merge nΓ£o reconhece como
|
|
29
|
+
* famΓlia e deixa passar junto com o `rounded-lg` do pacote β aΓ quem decide Γ© a ordem no CSS
|
|
30
|
+
* gerado, nΓ£o a do atributo. Nesse caso use a forma arbitrΓ‘ria (`rounded-[var(--radius-panel)]`),
|
|
31
|
+
* que o merge entende e substitui.
|
|
32
|
+
*/
|
|
33
|
+
className?: string
|
|
34
|
+
classNames?: DarkModeToggleClassNames
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_DARK_MODE_TOGGLE_LABELS: DarkModeToggleLabels = {
|
|
38
|
+
toDark: 'Tema escuro',
|
|
39
|
+
toLight: 'Tema claro',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Interruptor de tema, apresentacional: o estado vem do host, junto com `useDarkMode`.
|
|
44
|
+
*
|
|
45
|
+
* Ele nΓ£o monta o hook de propΓ³sito. `useDarkMode` Γ© o controlador β escreve a classe `dark` no
|
|
46
|
+
* `<html>` e persiste a escolha β, e uma barra lateral que tambΓ©m existe como gaveta fica montada
|
|
47
|
+
* duas vezes na mesma tela. Dois controladores guardam estados iniciais separados, e o segundo
|
|
48
|
+
* botΓ£o passa a mostrar o Γcone contrΓ‘rio ao tema que estΓ‘ no ar. Com o estado no host, o hook Γ©
|
|
49
|
+
* chamado uma vez acima dos dois e as duas cΓ³pias concordam.
|
|
50
|
+
*
|
|
51
|
+
* O rΓ³tulo nomeia o destino, nΓ£o o estado atual: no claro se lΓͺ "Tema escuro", que Γ© o que o clique
|
|
52
|
+
* faz. `aria-pressed` diria o oposto na mesma frase, entΓ£o fica de fora.
|
|
53
|
+
*/
|
|
54
|
+
export function DarkModeToggle({
|
|
55
|
+
isDark,
|
|
56
|
+
onToggle,
|
|
57
|
+
showLabel = false,
|
|
58
|
+
labels,
|
|
59
|
+
className,
|
|
60
|
+
classNames,
|
|
61
|
+
}: DarkModeToggleProps) {
|
|
62
|
+
const label = isDark
|
|
63
|
+
? (labels?.toLight ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toLight)
|
|
64
|
+
: (labels?.toDark ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toDark)
|
|
65
|
+
|
|
66
|
+
const Icon = isDark ? Sun : Moon
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={onToggle}
|
|
72
|
+
data-cv-tooltip={showLabel ? undefined : label}
|
|
73
|
+
aria-label={label}
|
|
74
|
+
className={cn(
|
|
75
|
+
'inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors',
|
|
76
|
+
'hover:bg-gray-100 hover:text-gray-900',
|
|
77
|
+
'dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100',
|
|
78
|
+
!showLabel && 'cv-touch',
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{/* Sem `size` quando o host manda classe: a prop vira `width`/`height` no SVG e atributo
|
|
83
|
+
ganha da folha, deixando o Γcone fora da escala de tipografia do host. */}
|
|
84
|
+
<Icon
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
className={cn('shrink-0', classNames?.icon)}
|
|
87
|
+
size={classNames?.icon ? undefined : ICON_SIZE_ACTION}
|
|
88
|
+
/>
|
|
89
|
+
{showLabel ? <span className={classNames?.label}>{label}</span> : null}
|
|
90
|
+
</button>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
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, useRef, useState } from 'react'
|
|
11
|
+
import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Upload, 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 { PAGINATION_LABELS } from './pagination.constant'
|
|
17
|
+
import { formatDateTime, formatFileSize } from './lib/format'
|
|
18
|
+
import { formatPhone } from './lib/phone'
|
|
19
|
+
import type { CompanyDocument } from './providers/types'
|
|
20
|
+
|
|
21
|
+
export interface DocumentsLibraryLabels {
|
|
22
|
+
title: string
|
|
23
|
+
searchPlaceholder: string
|
|
24
|
+
empty: string
|
|
25
|
+
noResults: string
|
|
26
|
+
loading: string
|
|
27
|
+
failure: string
|
|
28
|
+
view: string
|
|
29
|
+
download: string
|
|
30
|
+
openConversation: string
|
|
31
|
+
sourceFilterAll: string
|
|
32
|
+
sourceFilterCustomer: string
|
|
33
|
+
sourceFilterTeam: string
|
|
34
|
+
sortMostRecent: string
|
|
35
|
+
sortOldest: string
|
|
36
|
+
clearFilters: string
|
|
37
|
+
upload: string
|
|
38
|
+
uploadError: string
|
|
39
|
+
total: (count: number) => string
|
|
40
|
+
page: (current: number, last: number) => string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
|
|
44
|
+
title: 'Documentos',
|
|
45
|
+
searchPlaceholder: 'Buscar por nome do arquivo ou telefone',
|
|
46
|
+
empty: 'Nenhum arquivo trocado ainda.',
|
|
47
|
+
noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
|
|
48
|
+
loading: 'Carregando arquivosβ¦',
|
|
49
|
+
failure: 'NΓ£o foi possΓvel carregar os arquivos.',
|
|
50
|
+
view: 'Visualizar',
|
|
51
|
+
download: 'Baixar',
|
|
52
|
+
openConversation: 'Abrir conversa',
|
|
53
|
+
sourceFilterAll: 'Todas as origens',
|
|
54
|
+
sourceFilterCustomer: 'Cliente',
|
|
55
|
+
sourceFilterTeam: 'Equipe',
|
|
56
|
+
sortMostRecent: 'Mais recentes',
|
|
57
|
+
sortOldest: 'Mais antigos',
|
|
58
|
+
clearFilters: 'Limpar filtros',
|
|
59
|
+
upload: 'Enviar documento',
|
|
60
|
+
uploadError: 'NΓ£o foi possΓvel enviar o arquivo.',
|
|
61
|
+
total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
|
|
62
|
+
page: (current: number, last: number) => `${current} / ${last}`,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DocumentsLibraryClassNames {
|
|
66
|
+
root: string
|
|
67
|
+
title: string
|
|
68
|
+
filters: string
|
|
69
|
+
search: string
|
|
70
|
+
sourceSelect: string
|
|
71
|
+
sortButton: string
|
|
72
|
+
clearButton: string
|
|
73
|
+
status: string
|
|
74
|
+
list: string
|
|
75
|
+
item: string
|
|
76
|
+
conversationLink: string
|
|
77
|
+
filename: string
|
|
78
|
+
meta: string
|
|
79
|
+
pagination: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DocumentsLibraryProps {
|
|
83
|
+
/** Itens por pΓ‘gina. O total vem do servidor. */
|
|
84
|
+
perPage?: number
|
|
85
|
+
/** Abrir a conversa de origem. Ausente, o nΓΊmero aparece como texto e nΓ£o como link. */
|
|
86
|
+
onOpenConversation?: (conversationId: string) => void
|
|
87
|
+
labels?: Partial<DocumentsLibraryLabels>
|
|
88
|
+
className?: string
|
|
89
|
+
classNames?: Partial<DocumentsLibraryClassNames>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TEAM_SOURCES = new Set(['agent', 'bot'])
|
|
93
|
+
const DEFAULT_PER_PAGE = 20
|
|
94
|
+
|
|
95
|
+
export function DocumentsLibrary({
|
|
96
|
+
perPage = DEFAULT_PER_PAGE,
|
|
97
|
+
onOpenConversation,
|
|
98
|
+
labels: labelsOverride,
|
|
99
|
+
className,
|
|
100
|
+
classNames,
|
|
101
|
+
}: DocumentsLibraryProps) {
|
|
102
|
+
const labels = { ...DEFAULT_DOCUMENTS_LIBRARY_LABELS, ...labelsOverride }
|
|
103
|
+
const context = useConversations()
|
|
104
|
+
const [search, setSearch] = useState('')
|
|
105
|
+
const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
|
|
106
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
|
107
|
+
const [page, setPage] = useState(1)
|
|
108
|
+
const [documents, setDocuments] = useState<readonly CompanyDocument[]>([])
|
|
109
|
+
const [total, setTotal] = useState(0)
|
|
110
|
+
const [loading, setLoading] = useState(false)
|
|
111
|
+
const [failed, setFailed] = useState(false)
|
|
112
|
+
|
|
113
|
+
const [uploading, setUploading] = useState(false)
|
|
114
|
+
const [uploadFailed, setUploadFailed] = useState(false)
|
|
115
|
+
const [reloadToken, setReloadToken] = useState(0)
|
|
116
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
117
|
+
|
|
118
|
+
const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
|
|
119
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
|
120
|
+
const fetchAll = context?.api.getAllDocuments
|
|
121
|
+
const uploadDocument = context?.api.uploadDocument
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!fetchAll) return
|
|
125
|
+
let active = true
|
|
126
|
+
setLoading(true)
|
|
127
|
+
setFailed(false)
|
|
128
|
+
|
|
129
|
+
void fetchAll({
|
|
130
|
+
search,
|
|
131
|
+
page,
|
|
132
|
+
limit: perPage,
|
|
133
|
+
sortDirection,
|
|
134
|
+
...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
|
|
135
|
+
})
|
|
136
|
+
.then((result) => {
|
|
137
|
+
// `active` evita que uma resposta antiga sobrescreva a nova: digitar rΓ‘pido na busca dispara
|
|
138
|
+
// vΓ‘rias chamadas e a ordem de retorno nΓ£o Γ© garantida.
|
|
139
|
+
if (!active) return
|
|
140
|
+
setDocuments(result.documents)
|
|
141
|
+
setTotal(result.total)
|
|
142
|
+
})
|
|
143
|
+
.catch(() => {
|
|
144
|
+
if (active) setFailed(true)
|
|
145
|
+
})
|
|
146
|
+
.finally(() => {
|
|
147
|
+
if (active) setLoading(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
active = false
|
|
152
|
+
}
|
|
153
|
+
}, [fetchAll, search, sourceFilter, sortDirection, page, perPage, reloadToken])
|
|
154
|
+
|
|
155
|
+
function applyFilter(change: () => void): void {
|
|
156
|
+
change()
|
|
157
|
+
setPage(1)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
|
|
161
|
+
const url = await context?.api.getDocumentUrl(uploadId, disposition)
|
|
162
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function handleUpload(file: File): Promise<void> {
|
|
166
|
+
if (!uploadDocument) return
|
|
167
|
+
setUploading(true)
|
|
168
|
+
setUploadFailed(false)
|
|
169
|
+
try {
|
|
170
|
+
await uploadDocument(file)
|
|
171
|
+
setReloadToken((token) => token + 1)
|
|
172
|
+
} catch {
|
|
173
|
+
setUploadFailed(true)
|
|
174
|
+
} finally {
|
|
175
|
+
setUploading(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Host sem `getAllDocuments` nΓ£o tem o que mostrar aqui β some, em vez de renderizar vazio para
|
|
180
|
+
// sempre e fazer parecer que a empresa nΓ£o tem arquivo nenhum.
|
|
181
|
+
if (!fetchAll) return null
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className={cn('space-y-3', classNames?.root, className)}>
|
|
185
|
+
<h2 className={cn('text-lg font-semibold', classNames?.title)}>{labels.title}</h2>
|
|
186
|
+
|
|
187
|
+
<div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
|
|
188
|
+
<input
|
|
189
|
+
type="search"
|
|
190
|
+
value={search}
|
|
191
|
+
onChange={(event) => applyFilter(() => setSearch(event.target.value))}
|
|
192
|
+
placeholder={labels.searchPlaceholder}
|
|
193
|
+
aria-label={labels.searchPlaceholder}
|
|
194
|
+
className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-64', classNames?.search)}
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
<select
|
|
198
|
+
value={sourceFilter}
|
|
199
|
+
onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
|
|
200
|
+
aria-label={labels.sourceFilterAll}
|
|
201
|
+
className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-40', classNames?.sourceSelect)}
|
|
202
|
+
>
|
|
203
|
+
<option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
|
|
204
|
+
<option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
|
|
205
|
+
<option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
|
|
206
|
+
</select>
|
|
207
|
+
|
|
208
|
+
<button
|
|
209
|
+
data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
|
|
210
|
+
type="button"
|
|
211
|
+
onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
|
|
212
|
+
className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
|
|
213
|
+
>
|
|
214
|
+
<ArrowUpDown size={14} />
|
|
215
|
+
{sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
|
|
216
|
+
</button>
|
|
217
|
+
|
|
218
|
+
{hasFilters ? (
|
|
219
|
+
<button
|
|
220
|
+
data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={() =>
|
|
223
|
+
applyFilter(() => {
|
|
224
|
+
setSearch('')
|
|
225
|
+
setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
|
|
226
|
+
setSortDirection('desc')
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
className={cn('cv-header-action', classNames?.clearButton)}
|
|
230
|
+
>
|
|
231
|
+
{labels.clearFilters}
|
|
232
|
+
</button>
|
|
233
|
+
) : null}
|
|
234
|
+
|
|
235
|
+
{uploadDocument ? (
|
|
236
|
+
<>
|
|
237
|
+
<input
|
|
238
|
+
ref={fileInputRef}
|
|
239
|
+
type="file"
|
|
240
|
+
hidden
|
|
241
|
+
onChange={(event) => {
|
|
242
|
+
const file = event.target.files?.[0]
|
|
243
|
+
event.target.value = ''
|
|
244
|
+
if (file) void handleUpload(file)
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
<button
|
|
248
|
+
data-cv-tooltip={labels.upload}
|
|
249
|
+
aria-label={labels.upload}
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={() => fileInputRef.current?.click()}
|
|
252
|
+
disabled={uploading}
|
|
253
|
+
className="cv-header-action ml-auto inline-flex items-center gap-1 disabled:opacity-40"
|
|
254
|
+
>
|
|
255
|
+
<Upload size={14} aria-hidden="true" />
|
|
256
|
+
{labels.upload}
|
|
257
|
+
</button>
|
|
258
|
+
</>
|
|
259
|
+
) : null}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
|
|
263
|
+
{failed ? (
|
|
264
|
+
<p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
|
|
265
|
+
{labels.failure}
|
|
266
|
+
</p>
|
|
267
|
+
) : null}
|
|
268
|
+
{uploadFailed ? (
|
|
269
|
+
<p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
|
|
270
|
+
{labels.uploadError}
|
|
271
|
+
</p>
|
|
272
|
+
) : null}
|
|
273
|
+
{!loading && !failed && documents.length === 0 ? (
|
|
274
|
+
<p className={cn('text-sm text-gray-500', classNames?.status)}>
|
|
275
|
+
{hasFilters ? labels.noResults : labels.empty}
|
|
276
|
+
</p>
|
|
277
|
+
) : null}
|
|
278
|
+
|
|
279
|
+
<ul className={cn('space-y-2', classNames?.list)}>
|
|
280
|
+
{documents.map((document) => {
|
|
281
|
+
const isFromCustomer = !TEAM_SOURCES.has(document.source)
|
|
282
|
+
const SourceIcon = isFromCustomer ? Users : Bot
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<li
|
|
286
|
+
key={`${document.conversationId}:${document.id}`}
|
|
287
|
+
className={cn(
|
|
288
|
+
'flex items-center justify-between gap-3 rounded-lg border px-3 py-2 dark:border-gray-700',
|
|
289
|
+
classNames?.item,
|
|
290
|
+
)}
|
|
291
|
+
>
|
|
292
|
+
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
293
|
+
<FileIcon filename={document.filename} mimeType={document.mimeType} />
|
|
294
|
+
<div className="min-w-0 flex-1">
|
|
295
|
+
<div className={cn('truncate text-sm font-medium', classNames?.filename)} data-cv-tooltip={document.filename}>
|
|
296
|
+
{document.filename}
|
|
297
|
+
</div>
|
|
298
|
+
<div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
|
|
299
|
+
<span className="inline-flex items-center gap-1">
|
|
300
|
+
<SourceIcon size={11} />
|
|
301
|
+
{isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
|
|
302
|
+
</span>
|
|
303
|
+
<span>Β·</span>
|
|
304
|
+
<span>{formatDateTime(document.linkedAt)}</span>
|
|
305
|
+
<span>Β·</span>
|
|
306
|
+
<span>{formatFileSize(document.sizeBytes)}</span>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* A conversa de origem Γ© o dado que sΓ³ esta tela tem. Vira botΓ£o quando o host sabe
|
|
312
|
+
navegar; sem handler, fica texto β link que nΓ£o leva a lugar nenhum Γ© pior. */}
|
|
313
|
+
{onOpenConversation ? (
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
onClick={() => onOpenConversation(document.conversationId)}
|
|
317
|
+
data-cv-tooltip={labels.openConversation} aria-label={labels.openConversation}
|
|
318
|
+
className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
|
|
319
|
+
>
|
|
320
|
+
<MessageSquare size={12} />
|
|
321
|
+
{formatPhone(document.conversationId)}
|
|
322
|
+
</button>
|
|
323
|
+
) : (
|
|
324
|
+
<span className={cn('shrink-0 text-xs text-gray-500', classNames?.conversationLink)}>
|
|
325
|
+
{formatPhone(document.conversationId)}
|
|
326
|
+
</span>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
<div className="flex shrink-0 gap-1">
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
onClick={() => void handleOpen(document.id, 'inline')}
|
|
333
|
+
data-cv-tooltip={labels.view}
|
|
334
|
+
aria-label={`${labels.view}: ${document.filename}`}
|
|
335
|
+
className="cv-header-icon"
|
|
336
|
+
>
|
|
337
|
+
<Eye size={14} />
|
|
338
|
+
</button>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => void handleOpen(document.id, 'attachment')}
|
|
342
|
+
data-cv-tooltip={labels.download}
|
|
343
|
+
aria-label={`${labels.download}: ${document.filename}`}
|
|
344
|
+
className="cv-header-icon"
|
|
345
|
+
>
|
|
346
|
+
<Download size={14} />
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
</li>
|
|
350
|
+
)
|
|
351
|
+
})}
|
|
352
|
+
</ul>
|
|
353
|
+
|
|
354
|
+
{total > perPage ? (
|
|
355
|
+
<div className={cn('flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700', classNames?.pagination)}>
|
|
356
|
+
<span className="text-gray-400">{labels.total(total)}</span>
|
|
357
|
+
<div className="flex items-center gap-2">
|
|
358
|
+
<button
|
|
359
|
+
data-cv-tooltip={PAGINATION_LABELS.previous} aria-label={PAGINATION_LABELS.previous}
|
|
360
|
+
type="button"
|
|
361
|
+
onClick={() => setPage(page - 1)}
|
|
362
|
+
disabled={page <= 1}
|
|
363
|
+
className="cv-header-icon disabled:opacity-40"
|
|
364
|
+
>
|
|
365
|
+
βΉ
|
|
366
|
+
</button>
|
|
367
|
+
<span className="text-gray-500">{labels.page(page, lastPage)}</span>
|
|
368
|
+
<button
|
|
369
|
+
data-cv-tooltip={PAGINATION_LABELS.next} aria-label={PAGINATION_LABELS.next}
|
|
370
|
+
type="button"
|
|
371
|
+
onClick={() => setPage(page + 1)}
|
|
372
|
+
disabled={page >= lastPage}
|
|
373
|
+
className="cv-header-icon disabled:opacity-40"
|
|
374
|
+
>
|
|
375
|
+
βΊ
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
) : null}
|
|
380
|
+
</div>
|
|
381
|
+
)
|
|
382
|
+
}
|
package/src/EmojiPicker.tsx
CHANGED
|
@@ -1,39 +1,27 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react'
|
|
2
|
+
import { EMOJI_CATEGORIES, searchEmojis, type EmojiEntry } from './emojiCatalog'
|
|
3
|
+
|
|
4
|
+
export interface EmojiPickerLabels {
|
|
5
|
+
search: string
|
|
6
|
+
noResults: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_EMOJI_PICKER_LABELS: EmojiPickerLabels = {
|
|
10
|
+
search: 'Buscar emoji',
|
|
11
|
+
noResults: 'Nenhum emoji encontrado',
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
export interface EmojiPickerProps {
|
|
4
15
|
onSelect: (emoji: string) => void
|
|
16
|
+
labels?: Partial<EmojiPickerLabels>
|
|
5
17
|
className?: string
|
|
6
18
|
}
|
|
7
19
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
emojis: ['π', 'π', 'π', 'π', 'π
', 'π', 'π€£', 'π', 'π', 'π', 'π', 'π', 'π', 'π₯°', 'π', 'π', 'π', 'π', 'π', 'π€ͺ'],
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
name: 'Gestures',
|
|
15
|
-
emojis: ['π', 'π', 'π', 'βοΈ', 'π€', 'π€', 'π€', 'π€', 'π', 'π€', 'ποΈ', 'β', 'π', 'π', 'π', 'π€', 'π', 'βοΈ', 'π
', 'π€³'],
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
name: 'Hearts',
|
|
19
|
-
emojis: ['β€οΈ', 'π§‘', 'π', 'π', 'π', 'π', 'π€', 'π€', 'π€', 'π', 'β£οΈ', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'β₯οΈ'],
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
name: 'Food',
|
|
23
|
-
emojis: ['π', 'π', 'π', 'π', 'πΏ', 'π§', 'π₯', 'π₯', 'π³', 'π§', 'π₯', 'π§', 'π', 'π₯', 'π₯¨', 'π₯―', 'π₯', 'π§', 'π₯', 'π₯'],
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
name: 'Drinks',
|
|
27
|
-
emojis: ['β', 'π΅', 'πΆ', 'πΎ', 'π·', 'πΈ', 'πΉ', 'πΊ', 'π»', 'π₯', 'π₯', 'π₯€', 'π§', 'π§', 'π§', 'π§', 'π₯’', 'π½οΈ', 'π΄', 'π₯'],
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: 'Objects',
|
|
31
|
-
emojis: ['π', 'π', 'π', 'π', 'π', 'π', 'π±', 'π»', 'β', 'π·', 'π', 'π°', 'π³', 'π', 'π', 'π', 'βοΈ', 'π', 'π‘', 'π'],
|
|
32
|
-
},
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
|
|
20
|
+
export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerProps) => {
|
|
21
|
+
const searchLabel = labels?.search ?? DEFAULT_EMOJI_PICKER_LABELS.search
|
|
22
|
+
const noResultsLabel = labels?.noResults ?? DEFAULT_EMOJI_PICKER_LABELS.noResults
|
|
36
23
|
const [activeCategory, setActiveCategory] = useState(0)
|
|
24
|
+
const [query, setQuery] = useState('')
|
|
37
25
|
|
|
38
26
|
const handleSelect = useCallback(
|
|
39
27
|
(emoji: string) => {
|
|
@@ -42,36 +30,63 @@ export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
|
|
|
42
30
|
[onSelect],
|
|
43
31
|
)
|
|
44
32
|
|
|
33
|
+
// Buscando, as abas de categoria saem do caminho: o resultado atravessa todas elas, e manter uma
|
|
34
|
+
// aba destacada sugeriria que a busca estΓ‘ restrita Γ quela categoria.
|
|
35
|
+
const isSearching = query.trim().length > 0
|
|
36
|
+
const visibleEntries: readonly EmojiEntry[] = useMemo(
|
|
37
|
+
() => (isSearching ? searchEmojis(query) : EMOJI_CATEGORIES[activeCategory].entries),
|
|
38
|
+
[isSearching, query, activeCategory],
|
|
39
|
+
)
|
|
40
|
+
|
|
45
41
|
return (
|
|
46
42
|
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden ${className}`}>
|
|
47
|
-
<div className="
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}`}
|
|
57
|
-
>
|
|
58
|
-
{category.emojis[0]} {category.name}
|
|
59
|
-
</button>
|
|
60
|
-
))}
|
|
43
|
+
<div className="p-2 border-b border-gray-200">
|
|
44
|
+
<input
|
|
45
|
+
type="search"
|
|
46
|
+
value={query}
|
|
47
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
48
|
+
placeholder={searchLabel}
|
|
49
|
+
aria-label={searchLabel}
|
|
50
|
+
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm outline-none focus:border-blue-500"
|
|
51
|
+
/>
|
|
61
52
|
</div>
|
|
62
53
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
54
|
+
{isSearching ? null : (
|
|
55
|
+
<div className="flex border-b border-gray-200 overflow-x-auto">
|
|
56
|
+
{EMOJI_CATEGORIES.map((category, index) => (
|
|
57
|
+
<button
|
|
58
|
+
data-cv-tooltip={category.name} aria-label={category.name}
|
|
59
|
+
key={category.name}
|
|
60
|
+
onClick={() => setActiveCategory(index)}
|
|
61
|
+
className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
|
|
62
|
+
activeCategory === index
|
|
63
|
+
? 'border-blue-500 text-blue-600'
|
|
64
|
+
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
65
|
+
}`}
|
|
66
|
+
>
|
|
67
|
+
{category.entries[0].emoji} {category.name}
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{visibleEntries.length === 0 ? (
|
|
74
|
+
<p className="px-3 py-6 text-center text-sm text-gray-500">{noResultsLabel}</p>
|
|
75
|
+
) : (
|
|
76
|
+
<div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
|
|
77
|
+
{visibleEntries.map((entry) => (
|
|
78
|
+
<button
|
|
79
|
+
key={entry.emoji}
|
|
80
|
+
onClick={() => handleSelect(entry.emoji)}
|
|
81
|
+
className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
|
|
82
|
+
aria-label={entry.emoji}
|
|
83
|
+
data-cv-tooltip={entry.keywords[0]}
|
|
84
|
+
>
|
|
85
|
+
{entry.emoji}
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
75
90
|
</div>
|
|
76
91
|
)
|
|
77
92
|
}
|