@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.20

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.
Files changed (99) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-OIDAIVCH.js +1985 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +729 -136
  6. package/dist/index.js +1418 -1104
  7. package/dist/preview/index.d.ts +373 -0
  8. package/dist/preview/index.js +1247 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-O7kMP1Yn.d.ts +416 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/Avatar.tsx +30 -4
  15. package/src/ChannelIcon.tsx +87 -0
  16. package/src/ConversationContextPanel.tsx +127 -0
  17. package/src/ConversationDocumentsPanel.tsx +425 -0
  18. package/src/ConversationHeader.tsx +257 -0
  19. package/src/ConversationListItem.tsx +54 -7
  20. package/src/ConversationLocalesProvider.tsx +16 -0
  21. package/src/ConversationRow.tsx +137 -0
  22. package/src/DateDivider.tsx +16 -3
  23. package/src/DocumentsLibrary.tsx +322 -0
  24. package/src/EmojiPicker.tsx +69 -55
  25. package/src/FileIcon.test.ts +83 -0
  26. package/src/FileIcon.tsx +88 -11
  27. package/src/InteractiveMessage.test.tsx +41 -0
  28. package/src/InteractiveMessage.tsx +143 -0
  29. package/src/Lightbox.tsx +18 -3
  30. package/src/MediaRenderer.tsx +64 -21
  31. package/src/MessageBubble.tsx +54 -5
  32. package/src/MessageComposer.test.tsx +35 -0
  33. package/src/MessageComposer.tsx +146 -19
  34. package/src/RichMessageComposer.test.tsx +83 -0
  35. package/src/RichMessageComposer.tsx +380 -0
  36. package/src/Wallpaper.test.tsx +21 -0
  37. package/src/Wallpaper.tsx +54 -6
  38. package/src/WhatsAppMessageEditor.tsx +28 -4
  39. package/src/WindowExpiredNotice.tsx +57 -0
  40. package/src/audioRecorderFormat.test.ts +67 -0
  41. package/src/conversationChannel.test.ts +53 -0
  42. package/src/conversationChannel.ts +146 -0
  43. package/src/conversationTranscript.test.ts +65 -0
  44. package/src/conversationTranscript.ts +64 -0
  45. package/src/conversationWindow.test.ts +90 -0
  46. package/src/conversationWindow.ts +78 -0
  47. package/src/documentTypeLabel.test.ts +57 -0
  48. package/src/emojiCatalog.test.ts +35 -0
  49. package/src/emojiCatalog.ts +189 -0
  50. package/src/flows/FlowGroupHeader.tsx +12 -2
  51. package/src/flows/FlowMapCanvas.tsx +17 -14
  52. package/src/flows/FlowMapNode.tsx +3 -1
  53. package/src/flows/FlowNodeCard.tsx +22 -4
  54. package/src/flows/FlowNodePanel.tsx +132 -35
  55. package/src/flows/FlowPalette.tsx +6 -2
  56. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  57. package/src/flows/flowGraph.ts +5 -5
  58. package/src/flows/labels.ts +5 -0
  59. package/src/hooks/useConversationActions.ts +56 -0
  60. package/src/hooks/useConversationDocuments.ts +15 -9
  61. package/src/hooks/useConversationList.ts +15 -9
  62. package/src/hooks/useConversationMessages.ts +2 -2
  63. package/src/index.ts +129 -16
  64. package/src/lib/cn.test.ts +29 -0
  65. package/src/lib/cn.ts +15 -0
  66. package/src/lib/createMediaUrlResolver.ts +33 -0
  67. package/src/lib/paginated.test.ts +33 -0
  68. package/src/lib/paginated.ts +26 -0
  69. package/src/lib/phone.ts +34 -0
  70. package/src/preview/ConversationPreview.tsx +325 -0
  71. package/src/preview/MediaTypesPreview.tsx +87 -0
  72. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  73. package/src/preview/createMockConversationsApi.ts +271 -0
  74. package/src/preview/createMockSSEProvider.ts +40 -0
  75. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  76. package/src/preview/createPreviewBridgeClient.ts +88 -0
  77. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  78. package/src/preview/createPreviewWebhookClient.ts +131 -0
  79. package/src/preview/index.ts +61 -0
  80. package/src/preview/mediaTypeOf.test.ts +15 -0
  81. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  82. package/src/preview/mockEventSource.ts +53 -0
  83. package/src/preview/preview.test.ts +177 -0
  84. package/src/preview/previewFileSamples.test.ts +151 -0
  85. package/src/preview/previewFileSamples.ts +74 -0
  86. package/src/preview/previewFixtures.ts +440 -0
  87. package/src/preview/previewMediaSource.test.ts +62 -0
  88. package/src/preview/previewMediaSource.ts +91 -0
  89. package/src/preview/previewStore.ts +193 -0
  90. package/src/preview/startPreviewScript.ts +60 -0
  91. package/src/providers/types.ts +163 -11
  92. package/src/quickReply.test.ts +58 -0
  93. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  94. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  95. package/src/styles.css +136 -0
  96. package/src/types.ts +46 -1
  97. package/src/useDarkMode.ts +26 -0
  98. package/src/useIsNarrow.ts +29 -0
  99. package/src/useWaitingNotifications.ts +74 -29
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Como o preview entrega mídia para a UI abrir.
3
+ *
4
+ * Separado de `previewFileSamples` (que é gerado) porque aqui está a parte que depende do navegador:
5
+ * **blob URL, não data URL**. O Chrome bloqueia navegação de topo para `data:` desde a v60, então
6
+ * `window.open(dataUrl)` — que é o que os botões "visualizar" e "baixar" fazem — abre aba em branco
7
+ * mesmo com bytes perfeitamente válidos. Em `src` de `<img>`/`<video>` a data URL funcionaria; no
8
+ * `window.open` não, e o mesmo `getDocumentUrl` alimenta os dois.
9
+ */
10
+
11
+ import type { MessagePayload } from '../types'
12
+ import type { ResolveMediaUrl } from '../MediaRenderer'
13
+ import { resolvePreviewFileSample } from './previewFileSamples'
14
+ import { PREVIEW_DOCUMENTS } from './previewFixtures'
15
+
16
+ // Uma blob URL por tipo, reaproveitada: cada `createObjectURL` retém o blob até um `revokeObjectURL`
17
+ // que ninguém chamaria, e a lista redesenha a cada filtro digitado.
18
+ const blobUrlCache = new Map<string, string>()
19
+
20
+ function toBlob(dataUrl: string): Blob {
21
+ const [head, payload] = dataUrl.split(',')
22
+ const mimeType = head!
23
+ .replace(/^data:/, '')
24
+ .replace(/;base64$/, '')
25
+ .replace(/;charset=.*$/, '')
26
+
27
+ if (!head!.endsWith(';base64')) {
28
+ return new Blob([decodeURIComponent(payload!)], { type: mimeType || 'text/plain' })
29
+ }
30
+
31
+ const binary = atob(payload!)
32
+ const bytes = new Uint8Array(binary.length)
33
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
34
+ return new Blob([bytes], { type: mimeType })
35
+ }
36
+
37
+ /** URL que o preview pode abrir em aba nova. Cai na data URL fora do navegador (teste, SSR). */
38
+ export function previewFileUrl(mimeType: string | undefined, filename?: string): string {
39
+ const dataUrl = resolvePreviewFileSample(mimeType, filename)
40
+ if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return dataUrl
41
+
42
+ const cached = blobUrlCache.get(dataUrl)
43
+ if (cached) return cached
44
+
45
+ const blobUrl = URL.createObjectURL(toBlob(dataUrl))
46
+ blobUrlCache.set(dataUrl, blobUrl)
47
+ return blobUrl
48
+ }
49
+
50
+ /**
51
+ * A mesma amostra em base64 cru, como o proxy de mídia do backend a devolveria.
52
+ *
53
+ * Existe porque `getMediaProxyUrl` é o caminho da mídia AINDA NÃO ingerida (só existe o id na Meta),
54
+ * e o contrato pede `{ mimeType, data }` — não URL. Sem isto o mock devolvia o PNG 1x1 para
55
+ * qualquer id, e vídeo e áudio da thread apareciam quebrados apesar de haver amostra válida.
56
+ */
57
+ export function previewFileBase64(mimeType: string | undefined, filename?: string): { mimeType: string; data: string } {
58
+ const dataUrl = resolvePreviewFileSample(mimeType, filename)
59
+ const [head, payload] = dataUrl.split(',')
60
+ const declared = head!
61
+ .replace(/^data:/, '')
62
+ .replace(/;base64$/, '')
63
+ .replace(/;charset=.*$/, '')
64
+
65
+ if (head!.endsWith(';base64')) return { mimeType: declared, data: payload! }
66
+ return { mimeType: declared, data: btoa(decodeURIComponent(payload!)) }
67
+ }
68
+
69
+ /**
70
+ * O `onResolveMediaUrl` que o `MessageBubble` espera.
71
+ *
72
+ * Sem ele, foto, vídeo e áudio da thread ficam parados no placeholder para sempre — o
73
+ * `MediaRenderer` só resolve `uploadId`/`mediaId` por esta porta, de propósito, para o pacote nunca
74
+ * chamar endpoint fixo. O preview não tinha resolvedor nenhum, então nenhuma mídia carregava.
75
+ */
76
+ export function createPreviewMediaResolver(): ResolveMediaUrl {
77
+ const byId = new Map<string, { mimeType: string; filename: string }>()
78
+ for (const documents of Object.values(PREVIEW_DOCUMENTS)) {
79
+ for (const document of documents) {
80
+ byId.set(document.id, { mimeType: document.mimeType, filename: document.filename })
81
+ }
82
+ }
83
+
84
+ return async (message: MessagePayload): Promise<string | null> => {
85
+ const reference = message.uploadId ?? (message.mediaId ? `preview/inbound/${message.mediaId}` : undefined)
86
+ if (!reference) return null
87
+
88
+ const known = byId.get(reference)
89
+ return previewFileUrl(known?.mimeType ?? message.mimeType, known?.filename ?? message.filename)
90
+ }
91
+ }
@@ -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
+ }
@@ -1,37 +1,182 @@
1
1
  import type { MessagePayload } 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: string; languageCode?: string; bodyParams?: string[] },
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(conversationId: string, params?: { search?: string; page?: number }): Promise<ConversationDocument[]>
23
- getDocumentUrl(uploadId: string): Promise<string>
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
+ /**
140
+ * Superfície mínima de stream que o pacote consome — exatamente o que `useConversationRealtime`
141
+ * usa: assinar 'message', desassinar e fechar. Deliberadamente estrutural em vez de
142
+ * `EventSource`: sem servidor HTTP não existe `EventSource`, e é isso que impediria alimentar a
143
+ * inbox com dados mockados em desenvolvimento. Um `EventSource` nativo satisfaz este tipo, então
144
+ * quem já implementa `SSEProvider` continua válido sem mudança.
145
+ */
146
+ export interface ConversationEventSource {
147
+ addEventListener(type: string, listener: (event: MessageEvent) => void): void
148
+ removeEventListener(type: string, listener: (event: MessageEvent) => void): void
149
+ close(): void
25
150
  }
26
151
 
152
+ /**
153
+ * Eventos nomeados que o servidor realmente emite (`event: <nome>` no fio). O canal por conversa
154
+ * é `conv:<whatsappNumber>`; o global só emite `data-changed`, como sinal de "refaça a query".
155
+ * Tipar `addEventListener` como `string` em vez de `'message'` existe por isto: quem assina
156
+ * precisa alcançar `message-status` e `mode-changed`, não só `message`.
157
+ */
158
+ export const CONVERSATION_STREAM_EVENTS = ['message', 'message-status', 'mode-changed'] as const
159
+ export type ConversationStreamEvent = (typeof CONVERSATION_STREAM_EVENTS)[number]
160
+
161
+ export const GLOBAL_STREAM_EVENTS = ['data-changed'] as const
162
+ export type GlobalStreamEvent = (typeof GLOBAL_STREAM_EVENTS)[number]
163
+
27
164
  export interface SSEProvider {
28
- connectConversationStream(conversationId: string): EventSource
29
- connectGlobalStream(): EventSource
165
+ connectConversationStream(conversationId: string): ConversationEventSource
166
+ connectGlobalStream(): ConversationEventSource
30
167
  }
31
168
 
32
169
  export interface ConversationSummary {
33
170
  id: string
171
+ /**
172
+ * @deprecated Use `contactId` com `channel`. Mantido obrigatório para não quebrar quem já
173
+ * consome; some quando o segundo canal entrar em produção.
174
+ */
34
175
  whatsappNumber: string
176
+ /** Identificador neutro do contato. Ausente = usa `whatsappNumber`. */
177
+ contactId?: string
178
+ /** Ausente = `whatsapp`, o comportamento de antes desta mudança. */
179
+ channel?: ConversationChannel
35
180
  clientName?: string
36
181
  lastContent?: string
37
182
  lastDirection?: 'inbound' | 'outbound'
@@ -42,6 +187,13 @@ export interface ConversationSummary {
42
187
  waitingHuman: boolean
43
188
  unread: number
44
189
  currentState: string
190
+ /**
191
+ * Atributos que só o produto conhece e desenha (tipo de financiamento, carteira, campanha). É a
192
+ * contraparte de leitura do `filters` de `ListConversationsParams`: o pacote transporta e nunca
193
+ * interpreta. Sem isto, exibir um selo próprio na linha exigiria o host manter uma segunda
194
+ * consulta paralela à mesma listagem — a implementação duplicada que o pacote existe para evitar.
195
+ */
196
+ attributes?: Record<string, string | undefined>
45
197
  }
46
198
 
47
199
  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
+ })
@@ -24,6 +24,7 @@ export interface WhatsAppCreateTemplateFormLabels {
24
24
  sectionDescription: string
25
25
  nameLabel: string
26
26
  nameHint: string
27
+ namePlaceholder: string
27
28
  categoryLabel: string
28
29
  languageLabel: string
29
30
  headerLabel: string
@@ -57,6 +58,7 @@ const DEFAULT_LABELS: WhatsAppCreateTemplateFormLabels = {
57
58
  sectionDescription: 'Envia um template para aprovação da Meta.',
58
59
  nameLabel: 'Nome do template',
59
60
  nameHint: 'Somente letras minúsculas, números e underscore.',
61
+ namePlaceholder: 'reengajamento_cliente',
60
62
  categoryLabel: 'Categoria',
61
63
  languageLabel: 'Idioma',
62
64
  headerLabel: 'Cabeçalho',
@@ -142,7 +144,7 @@ export function WhatsAppCreateTemplateForm({
142
144
  type="text"
143
145
  value={value.name}
144
146
  onChange={(e) => set('name', e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
145
- placeholder="reengajamento_cliente"
147
+ placeholder={labels.namePlaceholder}
146
148
  className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all"
147
149
  required
148
150
  />