@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,57 @@
1
+ /**
2
+ * Aviso de janela de 24h expirada, no lugar do composer.
3
+ *
4
+ * Trocar o composer pelo aviso, em vez de deixar o campo lá e falhar no envio, é o ponto: fora da
5
+ * janela o WhatsApp recusa texto livre, e um campo habilitado promete algo que a plataforma não
6
+ * cumpre. O caminho que resta — template — fica visível na mesma altura da tela.
7
+ */
8
+
9
+ import { CONVERSATION_WINDOW, type ConversationWindow } from './conversationWindow'
10
+ import { cn } from './lib/cn'
11
+
12
+ export interface WindowExpiredNoticeLabels {
13
+ title: string
14
+ description: string
15
+ sendTemplate: string
16
+ }
17
+
18
+ export const DEFAULT_WINDOW_EXPIRED_LABELS: WindowExpiredNoticeLabels = {
19
+ title: '⏰ Janela de 24h expirada — o WhatsApp não aceita mensagens livres.',
20
+ description: 'Envie uma mensagem de template para reabrir a conversa com o cliente.',
21
+ sendTemplate: '📨 Enviar Template (HSM)',
22
+ }
23
+
24
+ export interface WindowExpiredNoticeProps {
25
+ onSendTemplate?: () => void
26
+ disabled?: boolean
27
+ labels?: Partial<WindowExpiredNoticeLabels>
28
+ className?: string
29
+ }
30
+
31
+ export function WindowExpiredNotice({
32
+ onSendTemplate,
33
+ disabled = false,
34
+ labels: labelsOverride,
35
+ className,
36
+ }: WindowExpiredNoticeProps) {
37
+ const labels = { ...DEFAULT_WINDOW_EXPIRED_LABELS, ...labelsOverride }
38
+
39
+ return (
40
+ <div role="status" className={cn('border-t bg-yellow-50 px-4 py-3 text-sm dark:bg-yellow-950', className)}>
41
+ <p className="font-medium text-yellow-900 dark:text-yellow-200">{labels.title}</p>
42
+ <p className="text-yellow-800 dark:text-yellow-300">{labels.description}</p>
43
+ {onSendTemplate ? (
44
+ <button type="button" onClick={onSendTemplate} disabled={disabled} className="cv-header-action mt-2">
45
+ {labels.sendTemplate}
46
+ </button>
47
+ ) : null}
48
+ </div>
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Só a faixa `expired` bloqueia: 21-24h ainda aceita texto livre e merece alerta, não impedimento.
54
+ */
55
+ export function isWindowBlocking(window: ConversationWindow): boolean {
56
+ return window === CONVERSATION_WINDOW.EXPIRED
57
+ }
@@ -0,0 +1,67 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+
3
+ import { resolveRecordingFormat } from './AudioRecorderButton'
4
+ import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
5
+
6
+ const originalMediaRecorder = (globalThis as Record<string, unknown>).MediaRecorder
7
+
8
+ function stubMediaRecorder(supported: readonly string[] | undefined): void {
9
+ const stub = supported ? { isTypeSupported: (mimeType: string) => supported.includes(mimeType) } : {}
10
+ ;(globalThis as Record<string, unknown>).MediaRecorder = stub
11
+ }
12
+
13
+ afterEach(() => {
14
+ ;(globalThis as Record<string, unknown>).MediaRecorder = originalMediaRecorder
15
+ })
16
+
17
+ describe('resolveRecordingFormat', () => {
18
+ it('prefere ogg/opus, que o WhatsApp aceita, sobre webm', () => {
19
+ stubMediaRecorder(['audio/ogg;codecs=opus', 'audio/webm'])
20
+
21
+ expect(resolveRecordingFormat()?.uploadMimeType).toBe('audio/ogg')
22
+ })
23
+
24
+ it('cai para mp4 no navegador que não grava ogg', () => {
25
+ stubMediaRecorder(['audio/mp4', 'audio/webm'])
26
+
27
+ const format = resolveRecordingFormat()
28
+ expect(format?.uploadMimeType).toBe('audio/mp4')
29
+ expect(format?.extension).toBe('m4a')
30
+ })
31
+
32
+ it('devolve indefinido quando o navegador não grava nenhum formato', () => {
33
+ stubMediaRecorder([])
34
+
35
+ expect(resolveRecordingFormat()).toBeUndefined()
36
+ })
37
+
38
+ it('devolve indefinido sem MediaRecorder no ambiente', () => {
39
+ ;(globalThis as Record<string, unknown>).MediaRecorder = undefined
40
+
41
+ expect(resolveRecordingFormat()).toBeUndefined()
42
+ })
43
+ })
44
+
45
+ describe('DEFAULT_ACCEPTED_FILE_TYPES', () => {
46
+ it('cobre Word, Excel e PowerPoint nos dois formatos, legado e OpenXML', () => {
47
+ const accepted = DEFAULT_ACCEPTED_FILE_TYPES.split(',')
48
+
49
+ expect(accepted).toContain('application/msword')
50
+ expect(accepted).toContain('application/vnd.openxmlformats-officedocument.wordprocessingml.document')
51
+ expect(accepted).toContain('application/vnd.ms-excel')
52
+ expect(accepted).toContain('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
53
+ expect(accepted).toContain('application/vnd.ms-powerpoint')
54
+ expect(accepted).toContain('application/vnd.openxmlformats-officedocument.presentationml.presentation')
55
+ })
56
+
57
+ it('cobre texto, PDF, áudio e vídeo, e não oferece formato que o WhatsApp recusa', () => {
58
+ const accepted = DEFAULT_ACCEPTED_FILE_TYPES.split(',')
59
+
60
+ expect(accepted).toContain('application/pdf')
61
+ expect(accepted).toContain('text/plain')
62
+ expect(accepted).toContain('audio/ogg')
63
+ expect(accepted).toContain('video/mp4')
64
+ expect(DEFAULT_ACCEPTED_FILE_TYPES).not.toContain('.zip')
65
+ expect(DEFAULT_ACCEPTED_FILE_TYPES).not.toContain('.rtf')
66
+ })
67
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { CONVERSATION_CHANNEL, CHANNEL_FILTER_ALL, channelFiltersFor } from './conversationChannel'
3
+
4
+ describe('channelFiltersFor', () => {
5
+ // O ponto do helper: a barra oferece só o que existe. Oferecer Instagram numa conta que só tem
6
+ // WhatsApp promete um recorte que nunca traz resultado.
7
+ it('lista apenas os canais presentes, com Todos na frente', () => {
8
+ const options = channelFiltersFor([
9
+ { channel: CONVERSATION_CHANNEL.WHATSAPP },
10
+ { channel: CONVERSATION_CHANNEL.INSTAGRAM },
11
+ { channel: CONVERSATION_CHANNEL.WHATSAPP },
12
+ ])
13
+
14
+ expect(options.map((option) => option.value)).toEqual([
15
+ CHANNEL_FILTER_ALL,
16
+ CONVERSATION_CHANNEL.WHATSAPP,
17
+ CONVERSATION_CHANNEL.INSTAGRAM,
18
+ ])
19
+ })
20
+
21
+ it('não oferece filtro quando há um canal só', () => {
22
+ expect(channelFiltersFor([{ channel: CONVERSATION_CHANNEL.WHATSAPP }, {}])).toEqual([])
23
+ })
24
+
25
+ it('não oferece filtro para lista vazia', () => {
26
+ expect(channelFiltersFor([])).toEqual([])
27
+ })
28
+
29
+ // Conversa sem canal é WhatsApp por compatibilidade — não pode virar uma quinta opção fantasma.
30
+ it('trata ausência de canal como whatsapp', () => {
31
+ const options = channelFiltersFor([{}, { channel: CONVERSATION_CHANNEL.WEBCHAT }])
32
+
33
+ expect(options.map((option) => option.value)).toEqual([
34
+ CHANNEL_FILTER_ALL,
35
+ CONVERSATION_CHANNEL.WHATSAPP,
36
+ CONVERSATION_CHANNEL.WEBCHAT,
37
+ ])
38
+ })
39
+
40
+ // Ordem do catálogo: se dependesse da ordem de chegada, a barra se reorganizaria a cada refetch.
41
+ it('mantém ordem estável independente da ordem da lista', () => {
42
+ const first = channelFiltersFor([
43
+ { channel: CONVERSATION_CHANNEL.WEBCHAT },
44
+ { channel: CONVERSATION_CHANNEL.WHATSAPP },
45
+ ])
46
+ const second = channelFiltersFor([
47
+ { channel: CONVERSATION_CHANNEL.WHATSAPP },
48
+ { channel: CONVERSATION_CHANNEL.WEBCHAT },
49
+ ])
50
+
51
+ expect(first).toEqual(second)
52
+ })
53
+ })
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Canal de origem da conversa e o que cada um permite.
3
+ *
4
+ * Existe porque as regras que a inbox precisa respeitar não são do WhatsApp, são **de cada canal**:
5
+ * a janela de sessão, o jeito de reabrir a conversa e o formato do identificador do contato mudam
6
+ * entre WhatsApp, Messenger, Instagram e chat de site. Tratar a regra do WhatsApp como universal
7
+ * faria a UI bloquear o composer num chat de site, onde janela nenhuma existe.
8
+ *
9
+ * `whatsapp` é o padrão em todo lugar: instalações que ainda não informam canal continuam
10
+ * funcionando exatamente como antes.
11
+ */
12
+
13
+ import { formatPhone, phoneCountryFlag } from './lib/phone'
14
+
15
+ export const CONVERSATION_CHANNEL = {
16
+ WHATSAPP: 'whatsapp',
17
+ MESSENGER: 'messenger',
18
+ INSTAGRAM: 'instagram',
19
+ WEBCHAT: 'webchat',
20
+ } as const
21
+ export type ConversationChannel = (typeof CONVERSATION_CHANNEL)[keyof typeof CONVERSATION_CHANNEL]
22
+
23
+ export const DEFAULT_CONVERSATION_CHANNEL: ConversationChannel = CONVERSATION_CHANNEL.WHATSAPP
24
+
25
+ /** Como o canal reabre uma conversa fora da janela de sessão. */
26
+ export const REOPEN_MECHANISM = {
27
+ TEMPLATE: 'template',
28
+ TAG: 'tag',
29
+ NONE: 'none',
30
+ } as const
31
+ export type ReopenMechanism = (typeof REOPEN_MECHANISM)[keyof typeof REOPEN_MECHANISM]
32
+
33
+ /** Natureza do identificador do contato — decide como exibi-lo. */
34
+ export const HANDLE_KIND = {
35
+ PHONE: 'phone',
36
+ USERNAME: 'username',
37
+ SESSION: 'session',
38
+ } as const
39
+ export type HandleKind = (typeof HANDLE_KIND)[keyof typeof HANDLE_KIND]
40
+
41
+ export type ChannelCapabilities = {
42
+ readonly label: string
43
+ readonly icon: string
44
+ readonly hasSessionWindow: boolean
45
+ readonly windowHours: number
46
+ readonly reopenMechanism: ReopenMechanism
47
+ readonly handleKind: HandleKind
48
+ }
49
+
50
+ export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelCapabilities>> = {
51
+ [CONVERSATION_CHANNEL.WHATSAPP]: {
52
+ label: 'WhatsApp',
53
+ icon: '💬',
54
+ hasSessionWindow: true,
55
+ windowHours: 24,
56
+ reopenMechanism: REOPEN_MECHANISM.TEMPLATE,
57
+ handleKind: HANDLE_KIND.PHONE,
58
+ },
59
+ [CONVERSATION_CHANNEL.MESSENGER]: {
60
+ // Messenger também tem 24h, mas reabre com message tag — não com template aprovado.
61
+ label: 'Messenger',
62
+ icon: '📨',
63
+ hasSessionWindow: true,
64
+ windowHours: 24,
65
+ reopenMechanism: REOPEN_MECHANISM.TAG,
66
+ handleKind: HANDLE_KIND.USERNAME,
67
+ },
68
+ [CONVERSATION_CHANNEL.INSTAGRAM]: {
69
+ label: 'Instagram',
70
+ icon: '📷',
71
+ hasSessionWindow: true,
72
+ windowHours: 24,
73
+ reopenMechanism: REOPEN_MECHANISM.TAG,
74
+ handleKind: HANDLE_KIND.USERNAME,
75
+ },
76
+ [CONVERSATION_CHANNEL.WEBCHAT]: {
77
+ // Chat próprio: sem intermediário, sem janela. Bloquear o composer aqui seria inventar limite.
78
+ label: 'Chat do site',
79
+ icon: '🌐',
80
+ hasSessionWindow: false,
81
+ windowHours: 0,
82
+ reopenMechanism: REOPEN_MECHANISM.NONE,
83
+ handleKind: HANDLE_KIND.SESSION,
84
+ },
85
+ }
86
+
87
+ export function capabilitiesOf(channel: ConversationChannel | undefined): ChannelCapabilities {
88
+ return CHANNEL_CAPABILITIES[channel ?? DEFAULT_CONVERSATION_CHANNEL]
89
+ }
90
+
91
+ export const CHANNEL_FILTER_ALL = 'all'
92
+ export type ChannelFilter = ConversationChannel | typeof CHANNEL_FILTER_ALL
93
+
94
+ export type ChannelFilterOption = {
95
+ readonly value: ChannelFilter
96
+ readonly label: string
97
+ }
98
+
99
+ /**
100
+ * Opções derivadas do que existe na lista, não do catálogo inteiro: oferecer Instagram numa conta
101
+ * que só tem WhatsApp promete um recorte que nunca traz resultado.
102
+ *
103
+ * Devolve vazio com menos de dois canais — um filtro de opção única não filtra nada, e a barra só
104
+ * ocuparia espaço. O host usa isso para esconder a seção.
105
+ */
106
+ export function channelFiltersFor(
107
+ conversations: readonly { readonly channel?: ConversationChannel | undefined }[],
108
+ ): ChannelFilterOption[] {
109
+ const present = new Set<ConversationChannel>(
110
+ conversations.map((conversation) => conversation.channel ?? DEFAULT_CONVERSATION_CHANNEL),
111
+ )
112
+
113
+ if (present.size < 2) return []
114
+
115
+ // Ordem do catálogo, não de chegada: a barra não pode reordenar sozinha a cada refetch.
116
+ const ordered = (Object.keys(CHANNEL_CAPABILITIES) as ConversationChannel[]).filter((channel) => present.has(channel))
117
+
118
+ return [
119
+ { value: CHANNEL_FILTER_ALL, label: 'Todos' },
120
+ ...ordered.map((channel) => ({ value: channel, label: CHANNEL_CAPABILITIES[channel].label })),
121
+ ]
122
+ }
123
+
124
+ export type FormatContactHandleParams = {
125
+ readonly handle: string
126
+ readonly channel?: ConversationChannel | undefined
127
+ }
128
+
129
+ /**
130
+ * Exibição do identificador conforme a natureza dele. Formatar tudo como telefone — o que a UI
131
+ * fazia — transforma um `@perfil` do Instagram em dígitos sem sentido.
132
+ */
133
+ export function formatContactHandle(params: FormatContactHandleParams): string {
134
+ const { handleKind } = capabilitiesOf(params.channel)
135
+
136
+ if (handleKind === HANDLE_KIND.PHONE) return formatPhone(params.handle)
137
+ if (handleKind === HANDLE_KIND.USERNAME) return params.handle.startsWith('@') ? params.handle : `@${params.handle}`
138
+
139
+ // Sessão de chat de site é um id opaco: mostrar o hash inteiro não ajuda ninguém.
140
+ return `Visitante ${params.handle.slice(-6)}`
141
+ }
142
+
143
+ /** Bandeira só faz sentido quando o identificador é telefone. */
144
+ export function contactFlag(params: FormatContactHandleParams): string {
145
+ return capabilitiesOf(params.channel).handleKind === HANDLE_KIND.PHONE ? phoneCountryFlag(params.handle) : ''
146
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { buildTranscriptFilename, buildTranscriptText } from './conversationTranscript'
3
+ import type { MessagePayload } from './types'
4
+
5
+ const NUMBER = '5511988887777'
6
+
7
+ function message(partial: Partial<MessagePayload>): MessagePayload {
8
+ return {
9
+ id: 'm1',
10
+ type: 'text',
11
+ direction: 'inbound',
12
+ sender: 'customer',
13
+ timestamp: '2026-07-27T12:00:00.000Z',
14
+ ...partial,
15
+ }
16
+ }
17
+
18
+ describe('buildTranscriptText', () => {
19
+ it('escreve cabeçalho e uma linha por mensagem', () => {
20
+ const text = buildTranscriptText({
21
+ messages: [
22
+ message({ id: 'm1', content: 'oi' }),
23
+ message({ id: 'm2', content: 'Olá!', direction: 'outbound', sender: 'bot' }),
24
+ ],
25
+ whatsappNumber: NUMBER,
26
+ clientName: 'Marina',
27
+ })
28
+
29
+ expect(text).toContain('Conversa: Marina')
30
+ expect(text).toContain(`Número: ${NUMBER}`)
31
+ expect(text).toContain('Mensagens: 2')
32
+ expect(text).toContain('Cliente: oi')
33
+ expect(text).toContain('Bot: Olá!')
34
+ })
35
+
36
+ // Sem isto a linha sairia vazia e o histórico esconderia que houve um anexo ali.
37
+ it('marca o tipo quando a mensagem não tem texto', () => {
38
+ const text = buildTranscriptText({ messages: [message({ type: 'audio' })], whatsappNumber: NUMBER })
39
+
40
+ expect(text).toContain('<audio>')
41
+ })
42
+
43
+ it('usa o nome do atendente quando existe, em vez do papel genérico', () => {
44
+ const text = buildTranscriptText({
45
+ messages: [message({ direction: 'outbound', sender: 'agent', agentName: 'Ana', content: 'já separei' })],
46
+ whatsappNumber: NUMBER,
47
+ })
48
+
49
+ expect(text).toContain('Ana: já separei')
50
+ })
51
+
52
+ it('cai no número quando não há nome do cliente', () => {
53
+ const text = buildTranscriptText({ messages: [], whatsappNumber: NUMBER })
54
+
55
+ expect(text).toContain(`Conversa: ${NUMBER}`)
56
+ })
57
+ })
58
+
59
+ describe('buildTranscriptFilename', () => {
60
+ it('inclui número e data', () => {
61
+ expect(buildTranscriptFilename(NUMBER, new Date('2026-07-27T23:00:00.000Z'))).toBe(
62
+ `conversa-${NUMBER}-2026-07-27.txt`,
63
+ )
64
+ })
65
+
66
+ /**
67
+ * O áudio saía como `<audio>` no arquivo baixado. Um histórico onde o pedido do cliente aparece
68
+ * como marcador vazio é inútil justamente para o caso que motiva o download: auditoria e repasse.
69
+ */
70
+ it('escreve a transcrição do áudio no lugar do marcador de tipo', () => {
71
+ const texto = buildTranscriptText({
72
+ whatsappNumber: '5511999999999',
73
+ messages: [
74
+ {
75
+ id: '1',
76
+ type: 'audio',
77
+ direction: 'inbound',
78
+ sender: 'customer',
79
+ timestamp: '2026-07-31T12:00:00.000Z',
80
+ transcription: { status: 'done', text: 'quero dois quilos de arroz' },
81
+ },
82
+ ],
83
+ })
84
+
85
+ expect(texto).toContain('[áudio] quero dois quilos de arroz')
86
+ expect(texto).not.toContain('<audio>')
87
+ })
88
+
89
+ it('mantém o marcador de tipo quando o áudio não foi transcrito', () => {
90
+ const texto = buildTranscriptText({
91
+ whatsappNumber: '5511999999999',
92
+ messages: [
93
+ { id: '1', type: 'audio', direction: 'inbound', sender: 'customer', timestamp: '2026-07-31T12:00:00.000Z' },
94
+ ],
95
+ })
96
+
97
+ expect(texto).toContain('<audio>')
98
+ })
99
+
100
+ /**
101
+ * A rota de export do módulo devolve `createdAt`, não `sentAt` — quem mapeia esperando `sentAt`
102
+ * entrega `undefined` aqui, e o arquivo saía com "Invalid Date" em todas as linhas.
103
+ */
104
+ it('escreve "data indisponível" em vez de Invalid Date quando o horário não vem', () => {
105
+ const texto = buildTranscriptText({
106
+ whatsappNumber: '5511999999999',
107
+ messages: [
108
+ {
109
+ id: '1',
110
+ type: 'text',
111
+ direction: 'inbound',
112
+ sender: 'customer',
113
+ content: 'ola',
114
+ timestamp: undefined as unknown as string,
115
+ },
116
+ ],
117
+ })
118
+
119
+ expect(texto).toContain('data indisponível')
120
+ expect(texto).not.toContain('Invalid Date')
121
+ })
122
+ })
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Serialização do transcript para download. Fica no pacote porque o formato de um histórico de
3
+ * WhatsApp legível não é regra de negócio de ninguém — e porque o host que já tem as mensagens em
4
+ * tela não deveria precisar de rota nova só para salvar um arquivo.
5
+ *
6
+ * Funções puras, separadas do disparo do download: é o que permite testá-las sem DOM.
7
+ */
8
+
9
+ import type { MessagePayload } from './types'
10
+
11
+ const SENDER_LABEL: Record<MessagePayload['sender'], string> = {
12
+ customer: 'Cliente',
13
+ bot: 'Bot',
14
+ agent: 'Atendente',
15
+ }
16
+
17
+ export type BuildTranscriptTextParams = {
18
+ readonly messages: readonly MessagePayload[]
19
+ readonly whatsappNumber: string
20
+ readonly clientName?: string | undefined
21
+ }
22
+
23
+ /**
24
+ * Formato próximo ao export nativo do WhatsApp (`[data hora] Autor: texto`), que é o que pessoas e
25
+ * ferramentas de suporte já sabem ler.
26
+ */
27
+ export function buildTranscriptText(params: BuildTranscriptTextParams): string {
28
+ const header = [
29
+ `Conversa: ${params.clientName ?? params.whatsappNumber}`,
30
+ `Número: ${params.whatsappNumber}`,
31
+ `Mensagens: ${params.messages.length}`,
32
+ '',
33
+ ]
34
+
35
+ const lines = params.messages.map((message) => {
36
+ const author = message.agentName ?? SENDER_LABEL[message.sender]
37
+ return `[${formatStamp(message.timestamp)}] ${author}: ${bodyOf(message)}`
38
+ })
39
+
40
+ return [...header, ...lines].join('\n')
41
+ }
42
+
43
+ /**
44
+ * Corpo da linha no arquivo.
45
+ *
46
+ * Áudio transcrito entra com o TEXTO, não como `<audio>`. Quem baixa a conversa quer lê-la, e um
47
+ * histórico onde o pedido do cliente aparece como marcador vazio é inútil justamente para o caso que
48
+ * motiva o download: auditoria e repasse. O prefixo `[áudio]` fica na frente para a linha não passar
49
+ * por mensagem digitada — quem audita precisa saber que aquilo foi falado e transcrito por máquina.
50
+ */
51
+ function bodyOf(message: MessagePayload): string {
52
+ const transcript = message.transcription?.text?.trim()
53
+ if (message.type === 'audio' && transcript) return `[áudio] ${transcript}`
54
+
55
+ // Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
56
+ return message.content ?? message.caption ?? `<${message.type}>`
57
+ }
58
+
59
+ /**
60
+ * `Invalid Date` no arquivo é pior do que data ausente: parece dado corrompido e põe em dúvida o
61
+ * resto do transcript. E acontece de verdade — a rota de export do módulo devolve `createdAt`, não
62
+ * `sentAt`, então quem mapeia esperando `sentAt` recebe `undefined` aqui.
63
+ */
64
+ function formatStamp(timestamp: string | undefined): string {
65
+ if (!timestamp) return 'data indisponível'
66
+
67
+ const parsed = new Date(timestamp)
68
+ return Number.isNaN(parsed.getTime()) ? 'data indisponível' : parsed.toLocaleString('pt-BR')
69
+ }
70
+
71
+ export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
72
+ const stamp = generatedAt.toISOString().slice(0, 10)
73
+ return `conversa-${whatsappNumber}-${stamp}.txt`
74
+ }
75
+
76
+ /**
77
+ * Dispara o download no navegador. `revokeObjectURL` no fim não é higiene opcional: sem ele cada
78
+ * export retém o blob inteiro em memória até a aba fechar.
79
+ */
80
+ export function downloadTextFile(filename: string, content: string): void {
81
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
82
+ const url = URL.createObjectURL(blob)
83
+ const anchor = document.createElement('a')
84
+
85
+ anchor.href = url
86
+ anchor.download = filename
87
+ anchor.click()
88
+ URL.revokeObjectURL(url)
89
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A classificação de janela decide se o atendente pode mandar texto livre. Errar a fronteira não
3
+ * quebra a tela — faz o WhatsApp recusar o envio depois, longe da causa.
4
+ */
5
+
6
+ import { describe, expect, it } from 'bun:test'
7
+ import { CONVERSATION_WINDOW, formatStalledFor, windowOf } from './conversationWindow'
8
+ import { CONVERSATION_CHANNEL, contactFlag, formatContactHandle } from './conversationChannel'
9
+
10
+ const NOW = new Date('2026-07-27T12:00:00.000Z').getTime()
11
+ const HOUR = 60 * 60 * 1000
12
+
13
+ function hoursAgo(hours: number): string {
14
+ return new Date(NOW - hours * HOUR).toISOString()
15
+ }
16
+
17
+ describe('windowOf', () => {
18
+ it('classifica por faixa de horas desde o último contato do cliente', () => {
19
+ expect(windowOf({ lastInboundAt: hoursAgo(0), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
20
+ expect(windowOf({ lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
21
+ expect(windowOf({ lastInboundAt: hoursAgo(15), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
22
+ expect(windowOf({ lastInboundAt: hoursAgo(22), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
23
+ expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
24
+ })
25
+
26
+ // As fronteiras são exatamente onde o erro custa caro: 24h em ponto já é recusa da Meta.
27
+ it('trata as fronteiras como início da faixa seguinte', () => {
28
+ expect(windowOf({ lastInboundAt: hoursAgo(12), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
29
+ expect(windowOf({ lastInboundAt: hoursAgo(21), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
30
+ expect(windowOf({ lastInboundAt: hoursAgo(24), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
31
+ })
32
+
33
+ // Sem inbound não há janela aberta; classificar como expirada evita prometer texto livre.
34
+ it('considera expirada quando o cliente nunca escreveu', () => {
35
+ expect(windowOf({ lastInboundAt: null, now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
36
+ })
37
+
38
+ // O defeito que isto tranca: aplicar a regra do WhatsApp ao chat de site bloquearia o composer
39
+ // num canal onde nada expira.
40
+ it('nunca expira em canal sem janela de sessão', () => {
41
+ expect(windowOf({ lastInboundAt: hoursAgo(720), now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
42
+ CONVERSATION_WINDOW.FRESH,
43
+ )
44
+ expect(windowOf({ lastInboundAt: null, now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
45
+ CONVERSATION_WINDOW.FRESH,
46
+ )
47
+ })
48
+
49
+ it('mantém a regra do WhatsApp quando o canal não é informado', () => {
50
+ expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(
51
+ windowOf({ lastInboundAt: hoursAgo(30), now: NOW, channel: CONVERSATION_CHANNEL.WHATSAPP }),
52
+ )
53
+ })
54
+ })
55
+
56
+ describe('formatContactHandle', () => {
57
+ it('formata telefone no WhatsApp e arroba nas redes', () => {
58
+ expect(formatContactHandle({ handle: '5511988887777', channel: CONVERSATION_CHANNEL.WHATSAPP })).toBe(
59
+ '+55 (11) 98888-7777',
60
+ )
61
+ expect(formatContactHandle({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe(
62
+ '@marina.alves',
63
+ )
64
+ expect(formatContactHandle({ handle: '@ja.tem', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('@ja.tem')
65
+ })
66
+
67
+ it('encurta a sessão anônima do chat de site', () => {
68
+ expect(formatContactHandle({ handle: 'sess_9f2a7c41b8', channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
69
+ 'Visitante 7c41b8',
70
+ )
71
+ })
72
+
73
+ // Bandeira em @perfil não significaria nada: o identificador não carrega país.
74
+ it('só devolve bandeira quando o identificador é telefone', () => {
75
+ expect(contactFlag({ handle: '5511988887777' })).toBe('🇧🇷')
76
+ expect(contactFlag({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('')
77
+ })
78
+ })
79
+
80
+ describe('formatStalledFor', () => {
81
+ it('formata dias, horas e minutos', () => {
82
+ expect(formatStalledFor(hoursAgo(5 * 24 + 6.2), NOW)).toBe('5d 6h:12m')
83
+ expect(formatStalledFor(hoursAgo(3.5), NOW)).toBe('3h:30m')
84
+ expect(formatStalledFor(hoursAgo(0.25), NOW)).toBe('15m')
85
+ })
86
+
87
+ it('não devolve tempo negativo para carimbo no futuro', () => {
88
+ expect(formatStalledFor(new Date(NOW + HOUR).toISOString(), NOW)).toBe('0m')
89
+ })
90
+ })