@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
package/src/lib/phone.ts CHANGED
@@ -20,6 +20,40 @@ export function formatPhone(number: string): string {
20
20
  return number
21
21
  }
22
22
 
23
+ // Mapa explícito em vez de derivar o emoji do código: prefixos são ambíguos (1 = EUA e Canadá,
24
+ // 7 = Rússia e Cazaquistão) e mostrar a bandeira errada é pior do que não mostrar nenhuma.
25
+ const COUNTRY_FLAG_BY_DIAL_CODE: Readonly<Record<string, string>> = {
26
+ '55': '🇧🇷',
27
+ '351': '🇵🇹',
28
+ '34': '🇪🇸',
29
+ '54': '🇦🇷',
30
+ '56': '🇨🇱',
31
+ '57': '🇨🇴',
32
+ '52': '🇲🇽',
33
+ '598': '🇺🇾',
34
+ '595': '🇵🇾',
35
+ '44': '🇬🇧',
36
+ '49': '🇩🇪',
37
+ '39': '🇮🇹',
38
+ '33': '🇫🇷',
39
+ }
40
+
41
+ /**
42
+ * Devolve string vazia quando não reconhece o país — quem renderiza decide se some com o espaço.
43
+ */
44
+ export function phoneCountryFlag(number: string): string {
45
+ const digits = number.replace(/\D/g, '')
46
+
47
+ // Do prefixo mais longo para o mais curto: '55' casaria antes de '551' e daria bandeira errada
48
+ // em países de código de três dígitos.
49
+ for (const length of [3, 2, 1]) {
50
+ const flag = COUNTRY_FLAG_BY_DIAL_CODE[digits.slice(0, length)]
51
+ if (flag) return flag
52
+ }
53
+
54
+ return ''
55
+ }
56
+
23
57
  export function phoneInitials(number: string): string {
24
58
  const digits = number.replace(/\D/g, '')
25
59
  return digits.slice(-2)
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Visão lado-cliente: você digita como se fosse o cliente no WhatsApp e vê o bot responder. A
3
+ * mensagem sai assinada para o webhook real, então o que roda aqui é o mesmo caminho de staging e
4
+ * produção — webhook, parser, motor de conversa.
5
+ *
6
+ * É o layout de conversa de verdade (wallpaper, divisor de data, agrupamento de bolhas), não uma
7
+ * casca de teste: o preview serve para julgar copy e fluxo, e isso só funciona se o que se vê
8
+ * aqui for o que o cliente vê no aparelho dele.
9
+ *
10
+ * O SSE só avisa que algo mudou (`{ direction, sender }`, sem conteúdo), então a chegada de um
11
+ * evento dispara refetch. É assim que o servidor funciona; renderizar direto do evento
12
+ * funcionaria no mock e quebraria em produção.
13
+ */
14
+
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
+ import type { InteractiveSelection, MessagePayload } from '../types'
17
+ import type { SSEProvider } from '../providers/types'
18
+ import { MessageBubble } from '../MessageBubble'
19
+ import { MessageComposer } from '../MessageComposer'
20
+ import { DateDivider } from '../DateDivider'
21
+ import { ConversationWallpaper } from '../Wallpaper'
22
+ import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
23
+ import { AudioRecorderButton } from '../AudioRecorderButton'
24
+
25
+ export type ConversationPreviewProps = {
26
+ client: PreviewWebhookClient
27
+ sse: SSEProvider
28
+ conversationId: string
29
+ loadMessages: (conversationId: string) => Promise<MessagePayload[]>
30
+ placeholder?: string
31
+ /**
32
+ * Recarrega o transcript a cada N ms. Serve a host SEM stream: a resposta do bot é assíncrona, e
33
+ * sem SSE nem polling ela só apareceria no próximo envio — o sintoma é "às vezes ele não
34
+ * responde". Ausente, não faz polling (host com SSE não precisa).
35
+ */
36
+ pollIntervalMs?: number
37
+ /**
38
+ * Como transformar um arquivo do disco (ou o áudio gravado) na referência que o webhook carrega.
39
+ * O caminho da Meta entrega mídia por `id`, e quem sabe hospedar o arquivo é o host — a SDK não
40
+ * inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
41
+ * botão que não existe do que um que falha ao ser tocado.
42
+ */
43
+ uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
44
+ }
45
+
46
+ export type PreviewUploadedMedia = {
47
+ readonly mediaId: string
48
+ readonly mimeType?: string
49
+ readonly filename?: string
50
+ }
51
+
52
+ /** Deriva o tipo de mídia do WhatsApp a partir do MIME do arquivo escolhido. */
53
+ export function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'] {
54
+ if (mimeType.startsWith('image/')) return 'image'
55
+ if (mimeType.startsWith('video/')) return 'video'
56
+ if (mimeType.startsWith('audio/')) return 'audio'
57
+ return 'document'
58
+ }
59
+
60
+ // Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
61
+ // recomeça um grupo (com rabicho e espaçamento maior).
62
+ const GROUPING_WINDOW_MS = 5 * 60 * 1000
63
+
64
+ // Escalonado, não um atraso fixo: a maioria das respostas chega em ~300ms, mas fluxo que consulta
65
+ // catálogo ou IA demora mais — e recarregar três vezes é mais barato que a conversa parecer morta.
66
+ const FOLLOW_UP_REFRESH_MS = [400, 1200, 3000]
67
+
68
+ type RenderedMessage = {
69
+ readonly message: MessagePayload
70
+ readonly isFirstInGroup: boolean
71
+ readonly showDateDivider: boolean
72
+ }
73
+
74
+ function decorate(messages: readonly MessagePayload[]): RenderedMessage[] {
75
+ return messages.map((message, index) => {
76
+ const previous = index > 0 ? messages[index - 1] : undefined
77
+ const currentTime = new Date(message.timestamp).getTime()
78
+ const previousTime = previous ? new Date(previous.timestamp).getTime() : 0
79
+
80
+ return {
81
+ message,
82
+ isFirstInGroup:
83
+ !previous || previous.sender !== message.sender || currentTime - previousTime > GROUPING_WINDOW_MS,
84
+ showDateDivider:
85
+ !previous || new Date(message.timestamp).toDateString() !== new Date(previous.timestamp).toDateString(),
86
+ }
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Status HTTP do erro, quando o host o preserva.
92
+ *
93
+ * O contrato não exige um tipo de erro — cada host tem o seu —, então a leitura é estrutural: basta
94
+ * carregar `status` (ou `statusCode`) para ser classificável. Host que joga `new Error(texto)` cai no
95
+ * caminho genérico, que ainda é melhor que silêncio.
96
+ */
97
+ function statusOf(error: unknown): number | undefined {
98
+ if (typeof error !== 'object' || error === null) return undefined
99
+ const candidate = error as { status?: unknown; statusCode?: unknown }
100
+ const value = candidate.status ?? candidate.statusCode
101
+ return typeof value === 'number' ? value : undefined
102
+ }
103
+
104
+ export function isNotFound(error: unknown): boolean {
105
+ return statusOf(error) === 404
106
+ }
107
+
108
+ export function describeLoadFailure(error: unknown): string {
109
+ const status = statusOf(error)
110
+ // 401/403 no simulador quase sempre é a aba sem sessão: `sessionStorage` é por aba, e link com
111
+ // `rel="noreferrer"` abre contexto novo que não herda o token do painel.
112
+ if (status === 401 || status === 403) {
113
+ return 'Sem sessão de administrador nesta aba: a mensagem é entregue no webhook, mas o transcript não pode ser lido. Entre no painel nesta mesma aba e reabra o simulador.'
114
+ }
115
+ if (error instanceof Error && error.message) return `Não foi possível ler o transcript: ${error.message}`
116
+ return 'Não foi possível ler o transcript da conversa.'
117
+ }
118
+
119
+ export function ConversationPreview({
120
+ client,
121
+ sse,
122
+ conversationId,
123
+ loadMessages,
124
+ placeholder,
125
+ pollIntervalMs,
126
+ uploadMedia,
127
+ }: ConversationPreviewProps) {
128
+ const [messages, setMessages] = useState<MessagePayload[]>([])
129
+ const [failure, setFailure] = useState<string | undefined>(undefined)
130
+ const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
131
+ const [isRecording, setIsRecording] = useState(false)
132
+ // Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
133
+ // ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
134
+ // indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
135
+ // tela é o servidor, que já tem a mensagem gravada.
136
+ const [pendingLocal, setPendingLocal] = useState<MessagePayload[]>([])
137
+ const loadMessagesRef = useRef(loadMessages)
138
+ const bottomRef = useRef<HTMLDivElement>(null)
139
+ loadMessagesRef.current = loadMessages
140
+
141
+ const refresh = useCallback(async (): Promise<void> => {
142
+ try {
143
+ const loaded = await loadMessagesRef.current(conversationId)
144
+ setMessages(loaded)
145
+ setLoadFailure(undefined)
146
+ // Só descarta o eco quando o servidor de fato devolveu conversa: lista vazia é "não consegui
147
+ // ver nada" (sessão ausente, primeiro contato), e limpar aí apagava da tela a mensagem que o
148
+ // usuário acabou de mandar — o sintoma que este eco existe para evitar.
149
+ if (loaded.length > 0) setPendingLocal([])
150
+ } catch (error) {
151
+ // Conversa que ainda não existe é o estado normal do primeiro contato — transcript vazio, sem
152
+ // alarme. QUALQUER outra falha precisa aparecer: engolir todas era o que transformava sessão
153
+ // expirada (401) em silêncio absoluto, com a thread limpa e o operador concluindo que o envio
154
+ // não funcionou — quando a mensagem tinha sido entregue no webhook.
155
+ if (isNotFound(error)) {
156
+ setMessages([])
157
+ setLoadFailure(undefined)
158
+ return
159
+ }
160
+
161
+ // Não limpa o que já está na tela: perder o histórico por causa de um refresh que falhou é
162
+ // dano maior que o próprio erro.
163
+ setLoadFailure(describeLoadFailure(error))
164
+ }
165
+ }, [conversationId])
166
+
167
+ useEffect(() => {
168
+ void refresh()
169
+ }, [refresh])
170
+
171
+ useEffect(() => {
172
+ const source = sse.connectConversationStream(conversationId)
173
+ const handler = (): void => {
174
+ void refresh()
175
+ }
176
+
177
+ source.addEventListener('message', handler)
178
+ return () => {
179
+ source.removeEventListener('message', handler)
180
+ source.close()
181
+ }
182
+ }, [sse, conversationId, refresh])
183
+
184
+ useEffect(() => {
185
+ if (!pollIntervalMs) return
186
+ const timer = setInterval(() => void refresh(), pollIntervalMs)
187
+ return () => clearInterval(timer)
188
+ }, [pollIntervalMs, refresh])
189
+
190
+ useEffect(() => {
191
+ // `block: 'nearest'` e não o padrão ('start'): o padrão alinha o elemento ao topo da área
192
+ // visível MAIS PRÓXIMA que role — e quando o container do preview não tem altura limitada, essa
193
+ // área é a PÁGINA. O efeito era a tela inteira saltar para baixo ao abrir/usar o simulador.
194
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
195
+ }, [messages])
196
+
197
+ const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
198
+
199
+ async function refreshWithFollowUps(): Promise<void> {
200
+ await refresh()
201
+ for (const atraso of FOLLOW_UP_REFRESH_MS) {
202
+ setTimeout(() => void refresh(), atraso)
203
+ }
204
+ }
205
+
206
+ async function handleSend(text: string): Promise<void> {
207
+ setFailure(undefined)
208
+ try {
209
+ await client.sendText(text)
210
+ setPendingLocal((current) => [
211
+ ...current,
212
+ {
213
+ id: `local-${current.length}-${text.length}`,
214
+ type: 'text',
215
+ content: text,
216
+ // Do ponto de vista do servidor, mensagem do cliente é inbound — é assim que ela aparece
217
+ // como "minha" nesta visão.
218
+ direction: 'inbound',
219
+ sender: 'customer',
220
+ timestamp: new Date().toISOString(),
221
+ status: 'sent',
222
+ },
223
+ ])
224
+ // O bot responde de forma assíncrona: gravar a resposta leva algumas centenas de ms depois do
225
+ // 200 do webhook. Um refresh único aqui frequentemente chegava ANTES dela, e a conversa ficava
226
+ // com a pergunta sem resposta até o envio seguinte.
227
+ await refreshWithFollowUps()
228
+ } catch (error) {
229
+ // A recusa mais provável é assinatura inválida (segredo divergente do que a API valida), e
230
+ // ela precisa aparecer na tela: silenciada, o sintoma vira "mandei e não aconteceu nada".
231
+ setFailure(error instanceof Error ? error.message : 'Falha ao entregar a mensagem no webhook.')
232
+ }
233
+ }
234
+
235
+ async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
236
+ setFailure(undefined)
237
+ const reply = { id: selection.option.id, title: selection.option.title }
238
+ try {
239
+ // Botão e lista são payloads diferentes para a Meta (`button_reply` × `list_reply`), e o
240
+ // roteador do fluxo lê campos distintos: tratar os dois como um só faria o menu responder no
241
+ // simulador e falhar no aparelho do cliente.
242
+ await (selection.kind === 'button' ? client.sendButtonReply(reply) : client.sendListReply(reply))
243
+ await refreshWithFollowUps()
244
+ } catch (error) {
245
+ setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
246
+ }
247
+ }
248
+
249
+ async function handleAttach(file: File): Promise<void> {
250
+ if (!uploadMedia) return
251
+ setFailure(undefined)
252
+ try {
253
+ const uploaded = await uploadMedia(file)
254
+ await client.sendMedia({
255
+ mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
256
+ mediaId: uploaded.mediaId,
257
+ mimeType: uploaded.mimeType ?? file.type,
258
+ filename: uploaded.filename ?? file.name,
259
+ })
260
+ await refreshWithFollowUps()
261
+ } catch (error) {
262
+ setFailure(error instanceof Error ? error.message : 'Falha ao enviar o arquivo.')
263
+ }
264
+ }
265
+
266
+ return (
267
+ <div className="flex h-full min-h-0 flex-col">
268
+ <ConversationWallpaper className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
269
+ {rendered.map(({ message, isFirstInGroup, showDateDivider }) => (
270
+ <div key={message.id}>
271
+ {showDateDivider ? <DateDivider iso={message.timestamp} /> : null}
272
+ <MessageBubble
273
+ message={message}
274
+ // Na visão do cliente, "minha" mensagem é a que ele enviou — inbound do ponto de
275
+ // vista do servidor.
276
+ isMine={message.direction === 'inbound'}
277
+ isFirstInGroup={isFirstInGroup}
278
+ // Só o que o bot ofereceu é tocável: reoferecer as opções da própria mensagem do
279
+ // cliente deixaria o menu clicável para sempre, o que o WhatsApp não faz.
280
+ onInteractiveSelect={
281
+ message.direction === 'outbound' ? (selection) => void handleInteractiveSelect(selection) : undefined
282
+ }
283
+ />
284
+ </div>
285
+ ))}
286
+ <div ref={bottomRef} />
287
+ </ConversationWallpaper>
288
+
289
+ {failure ? (
290
+ <p role="alert" className="px-4 py-2 text-sm text-red-600 dark:text-red-400">
291
+ {failure}
292
+ </p>
293
+ ) : null}
294
+
295
+ {/* Separado da falha de ENVIO: são causas diferentes e confundi-las manda o operador
296
+ investigar assinatura de webhook quando o problema é sessão. Amarelo, não vermelho — a
297
+ mensagem foi entregue; o que faltou foi poder ler a conversa de volta. */}
298
+ {loadFailure ? (
299
+ <p role="status" className="px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
300
+ {loadFailure}
301
+ </p>
302
+ ) : null}
303
+
304
+ <MessageComposer
305
+ onSend={(text) => void handleSend(text)}
306
+ onAttach={uploadMedia ? (file) => void handleAttach(file) : undefined}
307
+ /* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
308
+ que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
309
+ microfone está quebrado. */
310
+ placeholder={
311
+ isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
312
+ }
313
+ idleAction={
314
+ uploadMedia ? (
315
+ <AudioRecorderButton
316
+ onRecorded={(file) => void handleAttach(file)}
317
+ onFailure={(message) => setFailure(message)}
318
+ onRecordingChange={setIsRecording}
319
+ />
320
+ ) : undefined
321
+ }
322
+ />
323
+ </div>
324
+ )
325
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Bancada de teste manual de mídia — montável em uma linha por qualquer projeto que adote o SDK.
3
+ *
4
+ * Existe porque o defeito que ela pega não é pegável por teste automatizado: "o PDF abre?" depende
5
+ * do leitor do navegador, "o vídeo toca?" do decodificador, e "a aba abre?" da política do Chrome
6
+ * sobre `data:` URL. Teste unitário confere bytes; só o olho confere que o arquivo abre. Sem uma
7
+ * superfície pronta no pacote, cada projeto teria de montar a sua — e, na prática, nenhum montava.
8
+ *
9
+ * Traz o próprio store, o próprio mock e o próprio provider: o host não injeta nada. E não passa
10
+ * `onResolveMediaUrl` em lugar nenhum de propósito — é o `MessageBubble` resolvendo mídia pelo
11
+ * `ConversationsApi` do contexto, então se essa resolução automática quebrar, esta tela mostra.
12
+ */
13
+
14
+ import { useMemo } from 'react'
15
+ import { ConversationsProvider } from '../providers/ConversationsProvider'
16
+ import { DocumentsLibrary } from '../DocumentsLibrary'
17
+ import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
18
+ import { MessageBubble } from '../MessageBubble'
19
+ import { ConversationWallpaper } from '../Wallpaper'
20
+ import { createMockConversationsApi } from './createMockConversationsApi'
21
+ import { createMockSSEProvider } from './createMockSSEProvider'
22
+ import { createPreviewStore } from './previewStore'
23
+ import { PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_MESSAGES } from './previewFixtures'
24
+
25
+ /** A conversa do fixture que carrega uma mensagem de cada tipo aceito. */
26
+ export const MEDIA_TYPES_CONVERSATION_ID = '5511944443333'
27
+
28
+ export type MediaTypesPreviewProps = {
29
+ /** Outra conversa do fixture, se o projeto tiver acrescentado a sua. */
30
+ conversationId?: string
31
+ className?: string
32
+ }
33
+
34
+ export function MediaTypesPreview({
35
+ conversationId = MEDIA_TYPES_CONVERSATION_ID,
36
+ className,
37
+ }: MediaTypesPreviewProps) {
38
+ const store = useMemo(
39
+ () => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
40
+ [],
41
+ )
42
+ const api = useMemo(() => createMockConversationsApi({ store }), [store])
43
+ const sse = useMemo(() => createMockSSEProvider({ store }), [store])
44
+
45
+ const messages = PREVIEW_MESSAGES[conversationId] ?? []
46
+ const documents = PREVIEW_DOCUMENTS[conversationId] ?? []
47
+ const mimeTypes = [...new Set(documents.map((document) => document.mimeType))]
48
+
49
+ return (
50
+ <ConversationsProvider api={api} sse={sse}>
51
+ <div className={className}>
52
+ <header className="border-b px-4 py-3 dark:border-gray-700">
53
+ <h1 className="text-lg font-semibold">Teste manual de mídia</h1>
54
+ <p className="text-sm text-gray-500">
55
+ {documents.length} arquivos, {mimeTypes.length} tipos. Clique no olho para abrir em aba nova e no
56
+ botão da bolha para carregar a mídia na thread — é o que teste automatizado não vê.
57
+ </p>
58
+ </header>
59
+
60
+ <div className="grid gap-4 p-4 lg:grid-cols-2">
61
+ <section className="space-y-3">
62
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Biblioteca da empresa</h2>
63
+ {/* Sem paginar: a bancada serve para ver TODOS os tipos de uma vez. */}
64
+ <DocumentsLibrary perPage={documents.length || 20} />
65
+ </section>
66
+
67
+ <section className="space-y-3">
68
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Painel da conversa</h2>
69
+ <ConversationDocumentsPanel conversationId={conversationId} open perPage={documents.length || 20} />
70
+
71
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Bolhas na thread</h2>
72
+ <ConversationWallpaper className="max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2">
73
+ {messages.map((message, index) => (
74
+ <MessageBubble
75
+ key={message.id}
76
+ message={message}
77
+ isMine={message.direction === 'outbound'}
78
+ isFirstInGroup={index === 0 || messages[index - 1]?.sender !== message.sender}
79
+ />
80
+ ))}
81
+ </ConversationWallpaper>
82
+ </section>
83
+ </div>
84
+ </div>
85
+ </ConversationsProvider>
86
+ )
87
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Guarda a classificação de falha do simulador do cliente.
3
+ *
4
+ * O defeito que motivou o arquivo: o `refresh` tratava QUALQUER erro ao ler o transcript como
5
+ * "conversa ainda não existe", mostrava thread vazia e não dizia nada. Com sessão ausente (401) o
6
+ * sintoma era o pior possível — a mensagem ia para o webhook, era aceita, e a tela ficava igual.
7
+ * Quem olhava concluía que o envio estava quebrado.
8
+ */
9
+
10
+ import { describe, expect, it } from 'bun:test'
11
+
12
+ import { describeLoadFailure, isNotFound } from './ConversationPreview'
13
+
14
+ class HttpError extends Error {
15
+ constructor(
16
+ message: string,
17
+ readonly status: number,
18
+ ) {
19
+ super(message)
20
+ }
21
+ }
22
+
23
+ describe('isNotFound', () => {
24
+ // Primeiro contato: a conversa não existe e transcript vazio é o estado correto, sem alarme.
25
+ it('reconhece 404 como conversa inexistente', () => {
26
+ expect(isNotFound(new HttpError('Conversa não encontrada', 404))).toBe(true)
27
+ })
28
+
29
+ it('não confunde 401 com conversa inexistente', () => {
30
+ expect(isNotFound(new HttpError('Unauthorized', 401))).toBe(false)
31
+ })
32
+
33
+ it('erro sem status não é tratado como inexistente', () => {
34
+ expect(isNotFound(new Error('Failed to fetch'))).toBe(false)
35
+ expect(isNotFound(undefined)).toBe(false)
36
+ })
37
+
38
+ // Aceita `statusCode` também: nem todo host nomeia o campo igual.
39
+ it('lê statusCode quando é esse o nome do campo', () => {
40
+ expect(isNotFound({ statusCode: 404 })).toBe(true)
41
+ })
42
+ })
43
+
44
+ describe('describeLoadFailure', () => {
45
+ it('explica que falta sessão e que a mensagem FOI entregue', () => {
46
+ const mensagem = describeLoadFailure(new HttpError('Unauthorized', 401))
47
+
48
+ expect(mensagem).toContain('Sem sessão')
49
+ // O ponto central: não deixar o operador achar que o envio falhou.
50
+ expect(mensagem).toContain('entregue no webhook')
51
+ })
52
+
53
+ it('trata 403 igual a 401', () => {
54
+ expect(describeLoadFailure(new HttpError('Forbidden', 403))).toContain('Sem sessão')
55
+ })
56
+
57
+ it('preserva a mensagem do host em falha genérica', () => {
58
+ expect(describeLoadFailure(new HttpError('API fora do ar', 500))).toContain('API fora do ar')
59
+ })
60
+
61
+ it('tem texto para erro sem mensagem', () => {
62
+ expect(describeLoadFailure({}).length).toBeGreaterThan(0)
63
+ })
64
+ })