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

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 (52) hide show
  1. package/dist/chunk-N7B24WYD.js +719 -0
  2. package/dist/chunk-NV2RZ5KT.js +56 -0
  3. package/dist/{chunk-ZDURDZTM.js → chunk-OGRRHQQW.js} +1 -41
  4. package/dist/flows/index.js +6 -4
  5. package/dist/index.d.ts +316 -111
  6. package/dist/index.js +1032 -954
  7. package/dist/preview/index.d.ts +172 -0
  8. package/dist/preview/index.js +576 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-C0PtaO7S.d.ts +207 -0
  11. package/package.json +10 -3
  12. package/src/Avatar.tsx +18 -3
  13. package/src/ChannelIcon.tsx +87 -0
  14. package/src/ConversationContextPanel.tsx +106 -0
  15. package/src/ConversationDocumentsPanel.tsx +107 -0
  16. package/src/ConversationHeader.tsx +239 -0
  17. package/src/ConversationListItem.tsx +36 -5
  18. package/src/ConversationLocalesProvider.tsx +2 -0
  19. package/src/ConversationRow.tsx +137 -0
  20. package/src/DateDivider.tsx +16 -3
  21. package/src/MessageBubble.tsx +24 -2
  22. package/src/MessageComposer.tsx +15 -2
  23. package/src/Wallpaper.tsx +4 -2
  24. package/src/WindowExpiredNotice.tsx +57 -0
  25. package/src/conversationChannel.test.ts +53 -0
  26. package/src/conversationChannel.ts +146 -0
  27. package/src/conversationTranscript.test.ts +65 -0
  28. package/src/conversationTranscript.ts +64 -0
  29. package/src/conversationWindow.test.ts +90 -0
  30. package/src/conversationWindow.ts +78 -0
  31. package/src/flows/FlowMapCanvas.tsx +2 -2
  32. package/src/hooks/useConversationDocuments.ts +4 -2
  33. package/src/index.ts +73 -4
  34. package/src/lib/cn.ts +15 -0
  35. package/src/lib/phone.ts +34 -0
  36. package/src/preview/ConversationPreview.tsx +148 -0
  37. package/src/preview/createMockConversationsApi.ts +111 -0
  38. package/src/preview/createMockSSEProvider.ts +40 -0
  39. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  40. package/src/preview/createPreviewWebhookClient.ts +99 -0
  41. package/src/preview/index.ts +40 -0
  42. package/src/preview/mockEventSource.ts +53 -0
  43. package/src/preview/preview.test.ts +175 -0
  44. package/src/preview/previewFixtures.ts +153 -0
  45. package/src/preview/previewStore.ts +193 -0
  46. package/src/preview/startPreviewScript.ts +60 -0
  47. package/src/providers/types.ts +36 -2
  48. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  49. package/src/styles.css +136 -0
  50. package/src/types.ts +8 -0
  51. package/src/useDarkMode.ts +26 -0
  52. package/src/useIsNarrow.ts +29 -0
@@ -6,6 +6,7 @@ import { StatusTicks } from './StatusTicks'
6
6
  import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
7
7
  import { Lightbox } from './Lightbox'
8
8
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
9
+ import { cn } from './lib/cn'
9
10
  import { formatTimestamp, formatDateTime } from './lib/format'
10
11
 
11
12
  export interface MessageBubbleProps {
@@ -17,6 +18,7 @@ export interface MessageBubbleProps {
17
18
  isSelected?: boolean
18
19
  onToggleSelect?: () => void
19
20
  onResolveMediaUrl?: ResolveMediaUrl
21
+ className?: string
20
22
  }
21
23
 
22
24
  // Hex arbitrários (não os tokens `whatsapp.*` do Tailwind) — o pacote fica autocontido,
@@ -37,7 +39,7 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
37
39
  // tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
38
40
  export function MessageBubble({
39
41
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
40
- onResolveMediaUrl,
42
+ onResolveMediaUrl, className,
41
43
  }: MessageBubbleProps) {
42
44
  const { bubble, selection } = useConversationLocales()
43
45
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
@@ -73,7 +75,14 @@ export function MessageBubble({
73
75
  )
74
76
 
75
77
  return (
76
- <div className={`flex items-end gap-1.5 ${isMine ? 'justify-end' : 'justify-start'} group ${isFirstInGroup ? 'mt-2' : 'mt-0.5'}`}>
78
+ <div
79
+ className={cn(
80
+ 'flex items-end gap-1.5 group',
81
+ isMine ? 'justify-end' : 'justify-start',
82
+ isFirstInGroup ? 'mt-2' : 'mt-0.5',
83
+ className,
84
+ )}
85
+ >
77
86
  {isMine && checkbox}
78
87
  <div
79
88
  onClick={isSelecting ? onToggleSelect : undefined}
@@ -92,6 +101,19 @@ export function MessageBubble({
92
101
  </div>
93
102
  )}
94
103
 
104
+ {/* Sinaliza, não censura nem esconde: cliente xingando é cliente irritado, e o atendente
105
+ precisa ler o que foi dito para responder. A etiqueta serve para achar o caso na thread
106
+ e para justificar escalar. */}
107
+ {message.moderation?.isOffensive && (
108
+ <div
109
+ className="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
110
+ title={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
111
+ >
112
+ <span aria-hidden>⚠️</span>
113
+ <span>{bubble.moderationFlagged}</span>
114
+ </div>
115
+ )}
116
+
95
117
  {isMedia ? (
96
118
  <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={onResolveMediaUrl} />
97
119
  ) : (
@@ -1,5 +1,6 @@
1
1
  import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent } from 'react'
2
2
  import type { ConversationsFeatures } from './types'
3
+ import { cn } from './lib/cn'
3
4
  import { EmojiPicker } from './EmojiPicker'
4
5
 
5
6
  export interface MessageComposerProps {
@@ -12,6 +13,13 @@ export interface MessageComposerProps {
12
13
  maxLength?: number
13
14
  disabled?: boolean
14
15
  acceptedFileTypes?: string
16
+ className?: string
17
+ classNames?: Partial<MessageComposerClassNames>
18
+ }
19
+
20
+ export interface MessageComposerClassNames {
21
+ root: string
22
+ field: string
15
23
  }
16
24
 
17
25
  // Paridade com financiamento-imobiliario-bot/apps/web/src/pages/ConversationsPage.tsx:1490
@@ -32,6 +40,8 @@ export const MessageComposer = ({
32
40
  maxLength,
33
41
  disabled = false,
34
42
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
43
+ className,
44
+ classNames,
35
45
  }: MessageComposerProps) => {
36
46
  const [internalText, setInternalText] = useState('')
37
47
  const [showEmoji, setShowEmoji] = useState(false)
@@ -134,7 +144,10 @@ export const MessageComposer = ({
134
144
  const remaining = maxLength ? maxLength - text.length : null
135
145
 
136
146
  return (
137
- <div>
147
+ /* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
148
+ ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
149
+ descobriam o fundo branco da página, que lia como defeito. */
150
+ <div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
138
151
  {attachments.length > 0 && (
139
152
  <div className="flex gap-2 px-1 pb-2 overflow-x-auto">
140
153
  {attachments.map((a, i) => (
@@ -152,7 +165,7 @@ export const MessageComposer = ({
152
165
  </div>
153
166
  )}
154
167
 
155
- <div className="flex items-end gap-1.5 bg-[#f0f2f5] rounded-xl px-3 py-2">
168
+ <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
156
169
  {showEmojiButton && (
157
170
  <div className="relative flex-shrink-0">
158
171
  <button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label="Emoji">
package/src/Wallpaper.tsx CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
+ import { cn } from './lib/cn'
4
+
3
5
  export interface ConversationWallpaperProps {
4
6
  children?: ReactNode
5
7
  className?: string
@@ -8,6 +10,6 @@ export interface ConversationWallpaperProps {
8
10
  // Requer o import de '@adatechnology/conversations-ui/styles.css' uma vez no host —
9
11
  // a classe `.cv-wallpaper` (e sua variante `.dark`) vive nesse stylesheet, não em CSS-in-JS,
10
12
  // para não depender da configuração de Tailwind do consumidor.
11
- export function ConversationWallpaper({ children, className = '' }: ConversationWallpaperProps) {
12
- return <div className={`cv-wallpaper ${className}`.trim()}>{children}</div>
13
+ export function ConversationWallpaper({ children, className }: ConversationWallpaperProps) {
14
+ return <div className={cn('cv-wallpaper', className)}>{children}</div>
13
15
  }
@@ -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,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,65 @@
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
+ })
@@ -0,0 +1,64 @@
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 stamp = new Date(message.timestamp).toLocaleString('pt-BR')
37
+ const author = message.agentName ?? SENDER_LABEL[message.sender]
38
+ // Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
39
+ const body = message.content ?? message.caption ?? `<${message.type}>`
40
+ return `[${stamp}] ${author}: ${body}`
41
+ })
42
+
43
+ return [...header, ...lines].join('\n')
44
+ }
45
+
46
+ export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
47
+ const stamp = generatedAt.toISOString().slice(0, 10)
48
+ return `conversa-${whatsappNumber}-${stamp}.txt`
49
+ }
50
+
51
+ /**
52
+ * Dispara o download no navegador. `revokeObjectURL` no fim não é higiene opcional: sem ele cada
53
+ * export retém o blob inteiro em memória até a aba fechar.
54
+ */
55
+ export function downloadTextFile(filename: string, content: string): void {
56
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
57
+ const url = URL.createObjectURL(blob)
58
+ const anchor = document.createElement('a')
59
+
60
+ anchor.href = url
61
+ anchor.download = filename
62
+ anchor.click()
63
+ URL.revokeObjectURL(url)
64
+ }
@@ -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
+ })