@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.
Files changed (106) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-TV4OQRGH.js +2187 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +862 -136
  6. package/dist/index.js +1746 -1124
  7. package/dist/preview/index.d.ts +470 -0
  8. package/dist/preview/index.js +1329 -0
  9. package/dist/styles.css +228 -0
  10. package/dist/types-C6A_9edv.d.ts +456 -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/AudioTranscription.test.tsx +115 -0
  15. package/src/AudioTranscription.tsx +249 -0
  16. package/src/Avatar.tsx +30 -4
  17. package/src/ChannelIcon.tsx +87 -0
  18. package/src/ConversationContextPanel.tsx +280 -0
  19. package/src/ConversationDocumentsPanel.tsx +425 -0
  20. package/src/ConversationHeader.tsx +257 -0
  21. package/src/ConversationListItem.tsx +54 -7
  22. package/src/ConversationLocalesProvider.tsx +44 -0
  23. package/src/ConversationRow.tsx +137 -0
  24. package/src/DateDivider.tsx +16 -3
  25. package/src/DocumentsLibrary.tsx +322 -0
  26. package/src/EmojiPicker.tsx +69 -55
  27. package/src/FileIcon.test.ts +83 -0
  28. package/src/FileIcon.tsx +88 -11
  29. package/src/InteractiveMessage.test.tsx +41 -0
  30. package/src/InteractiveMessage.tsx +143 -0
  31. package/src/Lightbox.tsx +18 -3
  32. package/src/MediaRenderer.tsx +96 -22
  33. package/src/MessageBubble.tsx +77 -5
  34. package/src/MessageComposer.test.tsx +35 -0
  35. package/src/MessageComposer.tsx +165 -19
  36. package/src/RichMessageComposer.test.tsx +83 -0
  37. package/src/RichMessageComposer.tsx +380 -0
  38. package/src/Wallpaper.test.tsx +21 -0
  39. package/src/Wallpaper.tsx +69 -7
  40. package/src/WhatsAppMessageEditor.tsx +28 -4
  41. package/src/WindowExpiredNotice.tsx +57 -0
  42. package/src/audioRecorderFormat.test.ts +67 -0
  43. package/src/conversationChannel.test.ts +53 -0
  44. package/src/conversationChannel.ts +146 -0
  45. package/src/conversationTranscript.test.ts +122 -0
  46. package/src/conversationTranscript.ts +89 -0
  47. package/src/conversationWindow.test.ts +90 -0
  48. package/src/conversationWindow.ts +78 -0
  49. package/src/documentTypeLabel.test.ts +57 -0
  50. package/src/emojiCatalog.test.ts +35 -0
  51. package/src/emojiCatalog.ts +189 -0
  52. package/src/flows/FlowGroupHeader.tsx +12 -2
  53. package/src/flows/FlowMapCanvas.tsx +17 -14
  54. package/src/flows/FlowMapNode.tsx +3 -1
  55. package/src/flows/FlowNodeCard.tsx +22 -4
  56. package/src/flows/FlowNodePanel.tsx +132 -35
  57. package/src/flows/FlowPalette.tsx +6 -2
  58. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  59. package/src/flows/flowGraph.ts +5 -5
  60. package/src/flows/labels.ts +5 -0
  61. package/src/hooks/useConversationActions.ts +56 -0
  62. package/src/hooks/useConversationDocuments.ts +15 -9
  63. package/src/hooks/useConversationList.ts +15 -9
  64. package/src/hooks/useConversationMessages.ts +2 -2
  65. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  66. package/src/index.ts +140 -16
  67. package/src/lib/cn.test.ts +29 -0
  68. package/src/lib/cn.ts +15 -0
  69. package/src/lib/createMediaUrlResolver.ts +33 -0
  70. package/src/lib/paginated.test.ts +33 -0
  71. package/src/lib/paginated.ts +26 -0
  72. package/src/lib/phone.ts +34 -0
  73. package/src/preview/ConversationPreview.tsx +334 -0
  74. package/src/preview/MediaTypesPreview.tsx +87 -0
  75. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  76. package/src/preview/createMockConversationsApi.ts +271 -0
  77. package/src/preview/createMockSSEProvider.ts +40 -0
  78. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  79. package/src/preview/createPreviewBridgeClient.ts +124 -0
  80. package/src/preview/createPreviewMediaUploader.ts +82 -0
  81. package/src/preview/createPreviewWebhookClient.test.ts +194 -0
  82. package/src/preview/createPreviewWebhookClient.ts +222 -0
  83. package/src/preview/index.ts +67 -0
  84. package/src/preview/mediaTypeOf.test.ts +15 -0
  85. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  86. package/src/preview/mockEventSource.ts +53 -0
  87. package/src/preview/preview.test.ts +177 -0
  88. package/src/preview/previewFileSamples.test.ts +151 -0
  89. package/src/preview/previewFileSamples.ts +74 -0
  90. package/src/preview/previewFixtures.ts +440 -0
  91. package/src/preview/previewMediaSource.test.ts +62 -0
  92. package/src/preview/previewMediaSource.ts +91 -0
  93. package/src/preview/previewMediaUploader.test.ts +61 -0
  94. package/src/preview/previewStore.ts +193 -0
  95. package/src/preview/startPreviewScript.ts +60 -0
  96. package/src/providers/types.ts +175 -12
  97. package/src/quickReply.test.ts +58 -0
  98. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  99. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  100. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  101. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  102. package/src/styles.css +173 -0
  103. package/src/types.ts +72 -1
  104. package/src/useDarkMode.ts +26 -0
  105. package/src/useIsNarrow.ts +29 -0
  106. 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
+ }
@@ -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: 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
+ * 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): EventSource
29
- connectGlobalStream(): EventSource
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
+ })