@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21
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-TV4OQRGH.js +2187 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +862 -136
- package/dist/index.js +1746 -1124
- package/dist/preview/index.d.ts +470 -0
- package/dist/preview/index.js +1329 -0
- package/dist/styles.css +228 -0
- package/dist/types-C6A_9edv.d.ts +456 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +280 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +44 -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 +96 -22
- package/src/MessageBubble.tsx +77 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +165 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +69 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +122 -0
- package/src/conversationTranscript.ts +89 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- 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/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +140 -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/ConversationPreview.tsx +334 -0
- package/src/preview/MediaTypesPreview.tsx +87 -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/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 +194 -0
- package/src/preview/createPreviewWebhookClient.ts +222 -0
- package/src/preview/index.ts +67 -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/previewMediaUploader.test.ts +61 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +175 -12
- package/src/quickReply.test.ts +58 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +173 -0
- package/src/types.ts +72 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estado em memória que alimenta o preview de atendimento humano. Mock de API e mock de SSE
|
|
3
|
+
* compartilham este store de propósito: se cada um tivesse dados próprios, um evento anunciaria
|
|
4
|
+
* mensagem nova e o refetch da lista devolveria o estado antigo — a inbox pareceria funcionar e
|
|
5
|
+
* estaria mentindo, que é exatamente o defeito que o preview deveria expor.
|
|
6
|
+
*
|
|
7
|
+
* Os nomes de canal e de evento espelham o servidor (`conv:<whatsappNumber>`, `global`,
|
|
8
|
+
* `message`/`message-status`/`mode-changed`/`data-changed`). Fidelidade de vocabulário é o que
|
|
9
|
+
* permite trocar mock por servidor real sem tocar na UI.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MessagePayload } from '../types'
|
|
13
|
+
import type { ConversationSummary } from '../providers/types'
|
|
14
|
+
|
|
15
|
+
export const GLOBAL_CHANNEL = 'global'
|
|
16
|
+
|
|
17
|
+
export function conversationChannel(conversationId: string): string {
|
|
18
|
+
return `conv:${conversationId}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PreviewEmission = {
|
|
22
|
+
readonly channel: string
|
|
23
|
+
readonly event: string
|
|
24
|
+
readonly payload: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type PreviewStoreListener = (emission: PreviewEmission) => void
|
|
28
|
+
|
|
29
|
+
export type AppendMessageParams = {
|
|
30
|
+
readonly conversationId: string
|
|
31
|
+
readonly content: string
|
|
32
|
+
readonly direction: MessagePayload['direction']
|
|
33
|
+
readonly sender: MessagePayload['sender']
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SetModeParams = {
|
|
37
|
+
readonly conversationId: string
|
|
38
|
+
readonly mode: ConversationSummary['mode']
|
|
39
|
+
readonly assignedUserId?: string | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ListConversationsFilters = {
|
|
43
|
+
readonly waitingHuman?: boolean
|
|
44
|
+
readonly search?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PreviewStore = {
|
|
48
|
+
listConversations(filters?: ListConversationsFilters): ConversationSummary[]
|
|
49
|
+
listMessages(conversationId: string): MessagePayload[]
|
|
50
|
+
appendMessage(params: AppendMessageParams): MessagePayload
|
|
51
|
+
setMode(params: SetModeParams): void
|
|
52
|
+
requestHuman(conversationId: string): void
|
|
53
|
+
markRead(conversationId: string): void
|
|
54
|
+
subscribe(channel: string, listener: PreviewStoreListener): () => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type CreatePreviewStoreParams = {
|
|
58
|
+
readonly conversations: readonly ConversationSummary[]
|
|
59
|
+
readonly messages: Readonly<Record<string, readonly MessagePayload[]>>
|
|
60
|
+
// Injetável para o teste conseguir asserir timestamps sem depender do relógio.
|
|
61
|
+
readonly now?: () => Date
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createPreviewStore(params: CreatePreviewStoreParams): PreviewStore {
|
|
65
|
+
const conversations = params.conversations.map((conversation) => ({ ...conversation }))
|
|
66
|
+
const messages = new Map<string, MessagePayload[]>(
|
|
67
|
+
Object.entries(params.messages).map(([conversationId, list]) => [conversationId, [...list]]),
|
|
68
|
+
)
|
|
69
|
+
const listeners = new Map<string, Set<PreviewStoreListener>>()
|
|
70
|
+
const now = params.now ?? ((): Date => new Date())
|
|
71
|
+
|
|
72
|
+
let messageSequence = 0
|
|
73
|
+
|
|
74
|
+
function emit(emission: PreviewEmission): void {
|
|
75
|
+
for (const listener of listeners.get(emission.channel) ?? []) listener(emission)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Toda mutação avisa o canal global: é o sinal de "refaça a query" que mantém a lista coerente
|
|
79
|
+
// com o que o canal da conversa acabou de anunciar.
|
|
80
|
+
function emitDataChanged(): void {
|
|
81
|
+
emit({ channel: GLOBAL_CHANNEL, event: 'data-changed', payload: {} })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findConversation(conversationId: string): ConversationSummary | undefined {
|
|
85
|
+
return conversations.find((conversation) => conversation.id === conversationId)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
listConversations(filters?: ListConversationsFilters): ConversationSummary[] {
|
|
90
|
+
return conversations
|
|
91
|
+
.filter((conversation) => (filters?.waitingHuman ? conversation.waitingHuman : true))
|
|
92
|
+
.filter((conversation) => {
|
|
93
|
+
const search = filters?.search?.toLowerCase()
|
|
94
|
+
if (!search) return true
|
|
95
|
+
return (
|
|
96
|
+
conversation.whatsappNumber.includes(search) ||
|
|
97
|
+
(conversation.clientName?.toLowerCase().includes(search) ?? false)
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
.map((conversation) => ({ ...conversation }))
|
|
101
|
+
.sort((left, right) => right.lastAt.localeCompare(left.lastAt))
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
listMessages(conversationId: string): MessagePayload[] {
|
|
105
|
+
return [...(messages.get(conversationId) ?? [])]
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
appendMessage(appendParams: AppendMessageParams): MessagePayload {
|
|
109
|
+
messageSequence += 1
|
|
110
|
+
const timestamp = now().toISOString()
|
|
111
|
+
const message: MessagePayload = {
|
|
112
|
+
id: `preview-${messageSequence}`,
|
|
113
|
+
type: 'text',
|
|
114
|
+
content: appendParams.content,
|
|
115
|
+
direction: appendParams.direction,
|
|
116
|
+
sender: appendParams.sender,
|
|
117
|
+
timestamp,
|
|
118
|
+
status: appendParams.direction === 'outbound' ? 'sent' : undefined,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const conversationMessages = messages.get(appendParams.conversationId) ?? []
|
|
122
|
+
messages.set(appendParams.conversationId, [...conversationMessages, message])
|
|
123
|
+
|
|
124
|
+
const conversation = findConversation(appendParams.conversationId)
|
|
125
|
+
if (conversation) {
|
|
126
|
+
conversation.lastContent = appendParams.content
|
|
127
|
+
conversation.lastDirection = appendParams.direction
|
|
128
|
+
conversation.lastAt = timestamp
|
|
129
|
+
if (appendParams.direction === 'inbound') {
|
|
130
|
+
conversation.lastInboundAt = timestamp
|
|
131
|
+
conversation.unread += 1
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// O servidor emite APENAS `{ direction, sender }` neste evento — é ping de "refaça a
|
|
136
|
+
// query", não entrega de dados. Emitir a mensagem inteira aqui deixaria o mock mais
|
|
137
|
+
// generoso que a realidade, e uma UI que lesse `event.data.content` funcionaria no preview
|
|
138
|
+
// e quebraria em produção.
|
|
139
|
+
emit({
|
|
140
|
+
channel: conversationChannel(appendParams.conversationId),
|
|
141
|
+
event: 'message',
|
|
142
|
+
payload: { direction: message.direction, sender: message.sender },
|
|
143
|
+
})
|
|
144
|
+
emitDataChanged()
|
|
145
|
+
|
|
146
|
+
return message
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
setMode(modeParams: SetModeParams): void {
|
|
150
|
+
const conversation = findConversation(modeParams.conversationId)
|
|
151
|
+
if (!conversation) return
|
|
152
|
+
|
|
153
|
+
conversation.mode = modeParams.mode
|
|
154
|
+
conversation.assignedUserId = modeParams.assignedUserId ?? null
|
|
155
|
+
// Assumir a conversa é o que atende ao pedido de humano — deixar a flag acesa manteria a
|
|
156
|
+
// conversa na fila de espera para sempre.
|
|
157
|
+
if (modeParams.mode === 'human') conversation.waitingHuman = false
|
|
158
|
+
|
|
159
|
+
emit({
|
|
160
|
+
channel: conversationChannel(modeParams.conversationId),
|
|
161
|
+
event: 'mode-changed',
|
|
162
|
+
payload: { mode: conversation.mode, assignedUserId: conversation.assignedUserId },
|
|
163
|
+
})
|
|
164
|
+
emitDataChanged()
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
requestHuman(conversationId: string): void {
|
|
168
|
+
const conversation = findConversation(conversationId)
|
|
169
|
+
if (!conversation) return
|
|
170
|
+
|
|
171
|
+
conversation.waitingHuman = true
|
|
172
|
+
emitDataChanged()
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
markRead(conversationId: string): void {
|
|
176
|
+
const conversation = findConversation(conversationId)
|
|
177
|
+
if (!conversation) return
|
|
178
|
+
|
|
179
|
+
conversation.unread = 0
|
|
180
|
+
emitDataChanged()
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
subscribe(channel: string, listener: PreviewStoreListener): () => void {
|
|
184
|
+
const channelListeners = listeners.get(channel) ?? new Set<PreviewStoreListener>()
|
|
185
|
+
channelListeners.add(listener)
|
|
186
|
+
listeners.set(channel, channelListeners)
|
|
187
|
+
|
|
188
|
+
return () => {
|
|
189
|
+
channelListeners.delete(listener)
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roteiro que mantém o preview vivo: sem tráfego chegando, a inbox é uma tela estática e as
|
|
3
|
+
* transições que o atendente precisa testar (fila de espera enchendo, handoff, devolução ao bot)
|
|
4
|
+
* nunca acontecem.
|
|
5
|
+
*
|
|
6
|
+
* O roteiro é cíclico e determinístico — mesma ordem a cada execução. Aleatoriedade tornaria um
|
|
7
|
+
* defeito visto uma vez difícil de reencontrar.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PreviewStore } from './previewStore'
|
|
11
|
+
|
|
12
|
+
export type PreviewScriptStep = (store: PreviewStore) => void
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_PREVIEW_SCRIPT: readonly PreviewScriptStep[] = [
|
|
15
|
+
(store) =>
|
|
16
|
+
store.appendMessage({
|
|
17
|
+
conversationId: '5511988887777',
|
|
18
|
+
content: 'pode trocar o óleo por azeite?',
|
|
19
|
+
direction: 'inbound',
|
|
20
|
+
sender: 'customer',
|
|
21
|
+
}),
|
|
22
|
+
(store) => store.requestHuman('5511988887777'),
|
|
23
|
+
(store) =>
|
|
24
|
+
store.appendMessage({
|
|
25
|
+
conversationId: '5511955554444',
|
|
26
|
+
content: 'e o troco?',
|
|
27
|
+
direction: 'inbound',
|
|
28
|
+
sender: 'customer',
|
|
29
|
+
}),
|
|
30
|
+
(store) => store.setMode({ conversationId: '5511977776666', mode: 'human', assignedUserId: 'agent-2' }),
|
|
31
|
+
(store) =>
|
|
32
|
+
store.appendMessage({
|
|
33
|
+
conversationId: '5511977776666',
|
|
34
|
+
content: 'Oi Diego, sou a Ana. Já vi seu pedido.',
|
|
35
|
+
direction: 'outbound',
|
|
36
|
+
sender: 'agent',
|
|
37
|
+
}),
|
|
38
|
+
(store) => store.setMode({ conversationId: '5511966665555', mode: 'bot' }),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
export type StartPreviewScriptParams = {
|
|
42
|
+
readonly store: PreviewStore
|
|
43
|
+
readonly intervalMs?: number
|
|
44
|
+
readonly steps?: readonly PreviewScriptStep[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_INTERVAL_MS = 4000
|
|
48
|
+
|
|
49
|
+
export function startPreviewScript(params: StartPreviewScriptParams): () => void {
|
|
50
|
+
const steps = params.steps ?? DEFAULT_PREVIEW_SCRIPT
|
|
51
|
+
const intervalMs = params.intervalMs ?? DEFAULT_INTERVAL_MS
|
|
52
|
+
let index = 0
|
|
53
|
+
|
|
54
|
+
const timer = setInterval(() => {
|
|
55
|
+
steps[index % steps.length]?.(params.store)
|
|
56
|
+
index += 1
|
|
57
|
+
}, intervalMs)
|
|
58
|
+
|
|
59
|
+
return () => clearInterval(timer)
|
|
60
|
+
}
|
package/src/providers/types.ts
CHANGED
|
@@ -1,37 +1,193 @@
|
|
|
1
|
-
import type { MessagePayload } from '../types'
|
|
1
|
+
import type { MessagePayload, MessageTranscription } from '../types'
|
|
2
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
3
|
+
|
|
4
|
+
export interface ListConversationsParams {
|
|
5
|
+
page?: number
|
|
6
|
+
limit?: number
|
|
7
|
+
waitingHuman?: boolean
|
|
8
|
+
search?: string
|
|
9
|
+
/**
|
|
10
|
+
* Recortes que só o produto conhece (tipo de financiamento, carteira, campanha) repassados
|
|
11
|
+
* crus ao backend dele. É o que evita o vocabulário de uma vertical virar campo fixo aqui:
|
|
12
|
+
* o pacote transporta o filtro sem saber o que ele significa.
|
|
13
|
+
*/
|
|
14
|
+
filters?: Record<string, string | undefined>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Página com o total, para a UI conseguir desenhar controles de paginação.
|
|
19
|
+
*
|
|
20
|
+
* `fetchConversations` devolve isto **ou** o array puro de antes: implementações existentes
|
|
21
|
+
* continuam válidas sem mudar uma linha, e quem precisa paginar passa a ter o total. Sem a união
|
|
22
|
+
* seria impossível saber se um retorno curto é a última página ou uma página cheia por acaso.
|
|
23
|
+
*/
|
|
24
|
+
export interface ConversationPage {
|
|
25
|
+
conversations: ConversationSummary[]
|
|
26
|
+
total: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ListDocumentsParams {
|
|
30
|
+
search?: string
|
|
31
|
+
page?: number
|
|
32
|
+
/** Tamanho da página. Sem ele, `page` sozinho não define fatia nenhuma. */
|
|
33
|
+
limit?: number
|
|
34
|
+
/** Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host. */
|
|
35
|
+
source?: string
|
|
36
|
+
sortDirection?: 'asc' | 'desc'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
|
|
40
|
+
export interface CompanyDocument extends ConversationDocument {
|
|
41
|
+
conversationId: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CompanyDocumentPage {
|
|
45
|
+
documents: CompanyDocument[]
|
|
46
|
+
total: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ConversationDocumentPage {
|
|
50
|
+
documents: ConversationDocument[]
|
|
51
|
+
total: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Template disponível para envio a partir da inbox. Distinto do `WhatsAppTemplateSummary` de
|
|
56
|
+
* `settings/`, e de propósito: aquele serve ao formulário que **edita** template e carrega o que
|
|
57
|
+
* a edição precisa (`shortId`, `variableCount`); este serve a quem só vai **escolher um para
|
|
58
|
+
* enviar**, e pedir os campos de edição obrigaria todo host a produzi-los sem uso.
|
|
59
|
+
*/
|
|
60
|
+
export interface ConversationTemplate {
|
|
61
|
+
name: string
|
|
62
|
+
language: string
|
|
63
|
+
status: string
|
|
64
|
+
category?: string
|
|
65
|
+
bodyText?: string | null
|
|
66
|
+
}
|
|
2
67
|
|
|
3
68
|
export interface ConversationsApi {
|
|
4
69
|
fetchMessages(conversationId: string, params?: { limit?: number; before?: string }): Promise<MessagePayload[]>
|
|
5
|
-
fetchConversations(params?:
|
|
6
|
-
page?: number
|
|
7
|
-
limit?: number
|
|
8
|
-
waitingHuman?: boolean
|
|
9
|
-
search?: string
|
|
10
|
-
}): Promise<ConversationSummary[]>
|
|
70
|
+
fetchConversations(params?: ListConversationsParams): Promise<ConversationSummary[] | ConversationPage>
|
|
11
71
|
sendMessage(conversationId: string, text: string): Promise<MessagePayload>
|
|
12
72
|
sendMedia(
|
|
13
73
|
conversationId: string,
|
|
14
74
|
data: { base64: string; mimeType: string; filename: string; caption?: string },
|
|
15
75
|
): Promise<MessagePayload>
|
|
76
|
+
/**
|
|
77
|
+
* `templateName` é opcional porque reabrir a janela é a operação, e escolher *qual* template a
|
|
78
|
+
* usa nem sempre é decisão da UI: backends que guardam um template padrão configurado só
|
|
79
|
+
* precisam do "reabra". Exigir o nome obrigaria toda inbox a listar templates antes de poder
|
|
80
|
+
* mandar o primeiro — e a listagem é `listTemplates?`, opcional.
|
|
81
|
+
*/
|
|
16
82
|
sendTemplate(
|
|
17
83
|
conversationId: string,
|
|
18
|
-
data: { templateName
|
|
84
|
+
data: { templateName?: string; languageCode?: string; bodyParams?: string[] },
|
|
19
85
|
): Promise<void>
|
|
20
86
|
markRead(conversationId: string): Promise<void>
|
|
21
87
|
getContext(conversationId: string): Promise<Record<string, unknown>>
|
|
22
|
-
getDocuments(
|
|
23
|
-
|
|
88
|
+
getDocuments(
|
|
89
|
+
conversationId: string,
|
|
90
|
+
params?: ListDocumentsParams,
|
|
91
|
+
): Promise<ConversationDocument[] | ConversationDocumentPage>
|
|
92
|
+
/**
|
|
93
|
+
* `disposition` decide entre abrir no navegador e baixar. É o backend que assina a URL e grava
|
|
94
|
+
* o `Content-Disposition` nela, então a escolha precisa viajar na chamada — depois de assinada
|
|
95
|
+
* não há como o cliente mudá-la. Ausente = o padrão do host.
|
|
96
|
+
*/
|
|
97
|
+
getDocumentUrl(uploadId: string, disposition?: 'inline' | 'attachment'): Promise<string>
|
|
98
|
+
/**
|
|
99
|
+
* Baixa vários arquivos num zip único.
|
|
100
|
+
*
|
|
101
|
+
* **Opcional por capacidade:** montar zip exige o host LER os bytes do storage, o que nem toda
|
|
102
|
+
* instalação faz — as que só assinam URL não conseguem. Ausente, o painel esconde a seleção em
|
|
103
|
+
* lote em vez de oferecer um botão que falha.
|
|
104
|
+
*/
|
|
105
|
+
downloadDocumentsArchive?(conversationId: string, uploadIds: readonly string[]): Promise<Blob>
|
|
106
|
+
/**
|
|
107
|
+
* Biblioteca de TODAS as conversas, para uma tela de Documentos fora do atendimento.
|
|
108
|
+
*
|
|
109
|
+
* Opcional por capacidade: host que só expõe anexo dentro da conversa não implementa, e o
|
|
110
|
+
* componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
|
|
111
|
+
*/
|
|
112
|
+
getAllDocuments?(params?: ListDocumentsParams): Promise<CompanyDocumentPage>
|
|
24
113
|
getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Operações de atendimento humano. **Opcionais por capacidade, não por descuido:** nem toda
|
|
117
|
+
* inbox tem fila humana — um canal só-bot, ou um chat de site sem operador, não sabe o que é
|
|
118
|
+
* assumir conversa. Quem não implementa não ganha o botão, em vez de ganhar um botão que
|
|
119
|
+
* estoura no clique. Os hooks devolvem `undefined` para a ação ausente, e é isso que a UI
|
|
120
|
+
* consulta para decidir se desenha a afordância.
|
|
121
|
+
*/
|
|
122
|
+
takeover?(conversationId: string): Promise<void>
|
|
123
|
+
release?(conversationId: string): Promise<void>
|
|
124
|
+
/** Encerra o atendimento. Despedida, se houver, é decisão do host — o pacote não a inventa. */
|
|
125
|
+
finalize?(conversationId: string): Promise<void>
|
|
126
|
+
|
|
127
|
+
markAllRead?(): Promise<void>
|
|
128
|
+
listTemplates?(): Promise<ConversationTemplate[]>
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Transcrição completa gerada pelo servidor. Existe ao lado de `buildTranscriptText`, que monta
|
|
132
|
+
* a partir das mensagens já em memória: a tela costuma ter só a última página carregada, e
|
|
133
|
+
* exportar dali entregaria um recorte parcial com cara de histórico inteiro. Opcional porque
|
|
134
|
+
* nem todo backend expõe a rota — quem não tem continua usando o builder local.
|
|
135
|
+
*/
|
|
136
|
+
exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Transcreve o áudio de uma mensagem e devolve o resultado.
|
|
140
|
+
*
|
|
141
|
+
* **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
|
|
142
|
+
* nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
|
|
143
|
+
* simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
|
|
144
|
+
*
|
|
145
|
+
* `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
|
|
146
|
+
*/
|
|
147
|
+
transcribeAudio?(messageId: string): Promise<MessageTranscription>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Superfície mínima de stream que o pacote consome — exatamente o que `useConversationRealtime`
|
|
152
|
+
* usa: assinar 'message', desassinar e fechar. Deliberadamente estrutural em vez de
|
|
153
|
+
* `EventSource`: sem servidor HTTP não existe `EventSource`, e é isso que impediria alimentar a
|
|
154
|
+
* inbox com dados mockados em desenvolvimento. Um `EventSource` nativo satisfaz este tipo, então
|
|
155
|
+
* quem já implementa `SSEProvider` continua válido sem mudança.
|
|
156
|
+
*/
|
|
157
|
+
export interface ConversationEventSource {
|
|
158
|
+
addEventListener(type: string, listener: (event: MessageEvent) => void): void
|
|
159
|
+
removeEventListener(type: string, listener: (event: MessageEvent) => void): void
|
|
160
|
+
close(): void
|
|
25
161
|
}
|
|
26
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Eventos nomeados que o servidor realmente emite (`event: <nome>` no fio). O canal por conversa
|
|
165
|
+
* é `conv:<whatsappNumber>`; o global só emite `data-changed`, como sinal de "refaça a query".
|
|
166
|
+
* Tipar `addEventListener` como `string` em vez de `'message'` existe por isto: quem assina
|
|
167
|
+
* precisa alcançar `message-status` e `mode-changed`, não só `message`.
|
|
168
|
+
*/
|
|
169
|
+
export const CONVERSATION_STREAM_EVENTS = ['message', 'message-status', 'mode-changed'] as const
|
|
170
|
+
export type ConversationStreamEvent = (typeof CONVERSATION_STREAM_EVENTS)[number]
|
|
171
|
+
|
|
172
|
+
export const GLOBAL_STREAM_EVENTS = ['data-changed'] as const
|
|
173
|
+
export type GlobalStreamEvent = (typeof GLOBAL_STREAM_EVENTS)[number]
|
|
174
|
+
|
|
27
175
|
export interface SSEProvider {
|
|
28
|
-
connectConversationStream(conversationId: string):
|
|
29
|
-
connectGlobalStream():
|
|
176
|
+
connectConversationStream(conversationId: string): ConversationEventSource
|
|
177
|
+
connectGlobalStream(): ConversationEventSource
|
|
30
178
|
}
|
|
31
179
|
|
|
32
180
|
export interface ConversationSummary {
|
|
33
181
|
id: string
|
|
182
|
+
/**
|
|
183
|
+
* @deprecated Use `contactId` com `channel`. Mantido obrigatório para não quebrar quem já
|
|
184
|
+
* consome; some quando o segundo canal entrar em produção.
|
|
185
|
+
*/
|
|
34
186
|
whatsappNumber: string
|
|
187
|
+
/** Identificador neutro do contato. Ausente = usa `whatsappNumber`. */
|
|
188
|
+
contactId?: string
|
|
189
|
+
/** Ausente = `whatsapp`, o comportamento de antes desta mudança. */
|
|
190
|
+
channel?: ConversationChannel
|
|
35
191
|
clientName?: string
|
|
36
192
|
lastContent?: string
|
|
37
193
|
lastDirection?: 'inbound' | 'outbound'
|
|
@@ -42,6 +198,13 @@ export interface ConversationSummary {
|
|
|
42
198
|
waitingHuman: boolean
|
|
43
199
|
unread: number
|
|
44
200
|
currentState: string
|
|
201
|
+
/**
|
|
202
|
+
* Atributos que só o produto conhece e desenha (tipo de financiamento, carteira, campanha). É a
|
|
203
|
+
* contraparte de leitura do `filters` de `ListConversationsParams`: o pacote transporta e nunca
|
|
204
|
+
* interpreta. Sem isto, exibir um selo próprio na linha exigiria o host manter uma segunda
|
|
205
|
+
* consulta paralela à mesma listagem — a implementação duplicada que o pacote existe para evitar.
|
|
206
|
+
*/
|
|
207
|
+
attributes?: Record<string, string | undefined>
|
|
45
208
|
}
|
|
46
209
|
|
|
47
210
|
export interface ConversationDocument {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a interpolação das mensagens rápidas.
|
|
3
|
+
*
|
|
4
|
+
* O caso que decide o desenho: variável ausente. Deixar `{{nome}}` no texto significa o atendente
|
|
5
|
+
* mandar "Olá {{nome}}!" para o cliente — pior que a saudação sem nome.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
|
|
10
|
+
import { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
|
|
11
|
+
|
|
12
|
+
describe('applyQuickReplyVariables', () => {
|
|
13
|
+
it('troca a variável pelo valor', () => {
|
|
14
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!', { nome: 'Marina' })).toBe('Olá Marina!')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('aceita espaço dentro das chaves', () => {
|
|
18
|
+
expect(applyQuickReplyVariables('Olá {{ nome }}!', { nome: 'Rita' })).toBe('Olá Rita!')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('troca todas as ocorrências', () => {
|
|
22
|
+
expect(applyQuickReplyVariables('{{nome}}, confirma? Obrigado, {{nome}}.', { nome: 'Ana' })).toBe(
|
|
23
|
+
'Ana, confirma? Obrigado, Ana.',
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Nunca vaza o literal para o cliente.
|
|
28
|
+
it('apaga a variável que não foi passada', () => {
|
|
29
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!', {})).toBe('Olá !')
|
|
30
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!')).toBe('Olá !')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('não mexe em texto sem variável', () => {
|
|
34
|
+
expect(applyQuickReplyVariables('Bom dia!', { nome: 'X' })).toBe('Bom dia!')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('resolveQuickReply', () => {
|
|
39
|
+
it('interpola quando o texto é string', () => {
|
|
40
|
+
const resolvido = resolveQuickReply({ key: 'g', label: '👋', text: 'Olá {{nome}}!' }, { nome: 'Rita' })
|
|
41
|
+
|
|
42
|
+
expect(resolvido).toBe('Olá Rita!')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// A função existe para o que a string não resolve: escolher copy por produto, pluralizar, formatar.
|
|
46
|
+
it('chama a função com as variáveis', () => {
|
|
47
|
+
const resolvido = resolveQuickReply(
|
|
48
|
+
{ key: 's', label: '📋', text: (variables) => `Status de ${variables['produto'] ?? 'seu pedido'}` },
|
|
49
|
+
{ produto: 'financiamento' },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(resolvido).toBe('Status de financiamento')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('função sem variáveis não quebra', () => {
|
|
56
|
+
expect(resolveQuickReply({ key: 'c', label: '📞', text: () => 'Posso ligar?' })).toBe('Posso ligar?')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { TranscriptionSettingsForm } from './TranscriptionSettingsForm'
|
|
5
|
+
import type { TranscriptionSettingsFormProps } from './TranscriptionSettingsForm'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extrai uma tag pelo atributo, em vez de casar regex com a ordem dos atributos: o React SSR emite
|
|
9
|
+
* `checked=""` antes de `value=""`, então `/value="auto"[^>]*checked/` falha num componente correto.
|
|
10
|
+
*/
|
|
11
|
+
function findTag(markup: string, tagPattern: RegExp, contains: string): string | undefined {
|
|
12
|
+
return (markup.match(tagPattern) ?? []).find((tag) => tag.includes(contains))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function render(overrides: Partial<TranscriptionSettingsFormProps> = {}): string {
|
|
16
|
+
return renderToStaticMarkup(
|
|
17
|
+
<TranscriptionSettingsForm
|
|
18
|
+
enabled={false}
|
|
19
|
+
onEnabledChange={() => {}}
|
|
20
|
+
mode="onDemand"
|
|
21
|
+
onModeChange={() => {}}
|
|
22
|
+
onSave={() => {}}
|
|
23
|
+
{...overrides}
|
|
24
|
+
/>,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('TranscriptionSettingsForm', () => {
|
|
29
|
+
it('esconde a escolha de modo enquanto estiver desligado', () => {
|
|
30
|
+
const markup = render({ enabled: false })
|
|
31
|
+
|
|
32
|
+
expect(markup).not.toContain('Quando transcrever')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('mostra as duas opções de modo ao ligar', () => {
|
|
36
|
+
const markup = render({ enabled: true })
|
|
37
|
+
|
|
38
|
+
expect(markup).toContain('Quando transcrever')
|
|
39
|
+
expect(markup).toContain('Quando o atendente pedir')
|
|
40
|
+
expect(markup).toContain('Automaticamente')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('marca o modo atual', () => {
|
|
44
|
+
const markup = render({ enabled: true, mode: 'auto' })
|
|
45
|
+
const radios = /<input type="radio"[^>]*>/g
|
|
46
|
+
|
|
47
|
+
expect(findTag(markup, radios, 'value="auto"')).toContain('checked')
|
|
48
|
+
expect(findTag(markup, radios, 'value="onDemand"')).not.toContain('checked')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* "O ambiente consegue" é diferente de "esta empresa quer". Sem o aviso, o lojista ligaria o
|
|
53
|
+
* interruptor num ambiente sem engine, nada apareceria, e nada explicaria por quê.
|
|
54
|
+
*/
|
|
55
|
+
it('avisa e bloqueia quando a capacidade não existe no servidor', () => {
|
|
56
|
+
const markup = render({ isAvailable: false })
|
|
57
|
+
|
|
58
|
+
expect(markup).toContain('não está disponível neste ambiente')
|
|
59
|
+
expect(findTag(markup, /<input type="checkbox"[^>]*>/g, 'type="checkbox"')).toContain('disabled')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('não avisa nada quando a capacidade existe', () => {
|
|
63
|
+
const markup = render({ isAvailable: true })
|
|
64
|
+
|
|
65
|
+
expect(markup).not.toContain('não está disponível neste ambiente')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('aceita rótulos do host', () => {
|
|
69
|
+
const markup = render({ labels: { sectionTitle: 'Áudio em texto' } })
|
|
70
|
+
|
|
71
|
+
expect(markup).toContain('Áudio em texto')
|
|
72
|
+
expect(markup).not.toContain('Transcrição de áudio')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('desabilita o salvar enquanto salva', () => {
|
|
76
|
+
const markup = render({ saving: true })
|
|
77
|
+
|
|
78
|
+
expect(markup).toContain('Salvando...')
|
|
79
|
+
expect(findTag(markup, /<button type="submit"[^>]*>/g, 'type="submit"')).toContain('disabled')
|
|
80
|
+
})
|
|
81
|
+
})
|