@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.0

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 (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Camada única de dicas do pacote.
3
+ *
4
+ * O `title` nativo existia em todos os botões e mesmo assim a dica "não aparecia": o navegador
5
+ * espera de 1 a 2 segundos antes de desenhar, o balão sai fora do tema e some ao menor movimento do
6
+ * mouse. Aqui a dica é do produto — aparece em 120ms, com o mesmo contraste do resto da interface.
7
+ *
8
+ * Um só ouvinte no documento atende a interface inteira: cada botão declara `data-cv-tooltip` e não
9
+ * precisa de estado, ref ou wrapper próprio. O balão é impresso em `document.body` via portal
10
+ * porque `overflow` de container (a régua de respostas rápidas rola na horizontal) recortaria um
11
+ * balão desenhado dentro do próprio botão.
12
+ */
13
+
14
+ import { useEffect, useRef, useState } from 'react'
15
+ import { createPortal } from 'react-dom'
16
+
17
+ /** Atributo que marca um elemento como portador de dica. */
18
+ export const TOOLTIP_ATTRIBUTE = 'data-cv-tooltip'
19
+
20
+ const TOOLTIP_DELAY_MS = 120
21
+ const TOOLTIP_GAP_PX = 8
22
+ /** Margem para o balão não colar na borda da janela quando o alvo está no canto. */
23
+ const TOOLTIP_EDGE_PX = 72
24
+
25
+ export type TooltipPlacement = 'top' | 'bottom'
26
+
27
+ export interface TooltipPositionParams {
28
+ readonly targetRect: { top: number; bottom: number; left: number; width: number }
29
+ readonly viewportWidth: number
30
+ }
31
+
32
+ export interface TooltipPosition {
33
+ left: number
34
+ top: number
35
+ placement: TooltipPlacement
36
+ }
37
+
38
+ interface TooltipState extends TooltipPosition {
39
+ text: string
40
+ }
41
+
42
+ /** Separado do componente por ser a única parte com regra: o resto é ouvir evento e desenhar. */
43
+ export function tooltipPositionOf({ targetRect, viewportWidth }: TooltipPositionParams): TooltipPosition {
44
+ // Sem espaço acima (botão no topo da tela), o balão desce em vez de sair da janela.
45
+ const placement: TooltipPlacement = targetRect.top < TOOLTIP_EDGE_PX ? 'bottom' : 'top'
46
+ const centerX = targetRect.left + targetRect.width / 2
47
+
48
+ return {
49
+ left: Math.min(Math.max(centerX, TOOLTIP_EDGE_PX), viewportWidth - TOOLTIP_EDGE_PX),
50
+ top: placement === 'top' ? targetRect.top - TOOLTIP_GAP_PX : targetRect.bottom + TOOLTIP_GAP_PX,
51
+ placement,
52
+ }
53
+ }
54
+
55
+ function tooltipStateFor(target: Element, text: string): TooltipState {
56
+ const position = tooltipPositionOf({
57
+ targetRect: target.getBoundingClientRect(),
58
+ viewportWidth: window.innerWidth,
59
+ })
60
+
61
+ return { text, ...position }
62
+ }
63
+
64
+ /**
65
+ * Só uma camada desenha por vez. A camada é montada por cada superfície do pacote (inbox, fluxos,
66
+ * documentos) para o host não precisar montá-la à mão; duas superfícies na mesma tela renderizariam
67
+ * dois balões sobrepostos sem essa trava.
68
+ */
69
+ let layerOwner: object | undefined
70
+
71
+ export function TooltipLayer() {
72
+ const identity = useRef({})
73
+ const [isOwner, setIsOwner] = useState(false)
74
+ const [tooltip, setTooltip] = useState<TooltipState | undefined>(undefined)
75
+
76
+ useEffect(() => {
77
+ const owned = identity.current
78
+ if (layerOwner && layerOwner !== owned) return undefined
79
+ layerOwner = owned
80
+ setIsOwner(true)
81
+
82
+ return () => {
83
+ if (layerOwner === owned) layerOwner = undefined
84
+ }
85
+ }, [])
86
+
87
+ useEffect(() => {
88
+ if (!isOwner) return undefined
89
+
90
+ let timer: ReturnType<typeof setTimeout> | undefined
91
+
92
+ function cancelPending() {
93
+ if (timer) clearTimeout(timer)
94
+ timer = undefined
95
+ }
96
+
97
+ function hide() {
98
+ cancelPending()
99
+ setTooltip(undefined)
100
+ }
101
+
102
+ function handleEnter(event: Event) {
103
+ const origin = event.target
104
+ if (!(origin instanceof Element)) return
105
+ // `Element`, e não `HTMLElement`: ícone é `<svg>`, que não é HTMLElement. Exigir HTMLElement
106
+ // fazia a dica de um ícone não só falhar — ela ENGOLIA a do card ao redor, porque o
107
+ // `closest` já tinha parado no svg e a busca não continuava para cima.
108
+ const target = origin.closest(`[${TOOLTIP_ATTRIBUTE}]`)
109
+ if (!target) {
110
+ hide()
111
+ return
112
+ }
113
+ const text = target.getAttribute(TOOLTIP_ATTRIBUTE)
114
+ if (!text) {
115
+ hide()
116
+ return
117
+ }
118
+ cancelPending()
119
+ timer = setTimeout(() => setTooltip(tooltipStateFor(target, text)), TOOLTIP_DELAY_MS)
120
+ }
121
+
122
+ // Sair da janela não dispara `pointerover` em elemento nenhum: sem isto, o balão ficaria aceso
123
+ // depois que o ponteiro já saiu da página.
124
+ function handleLeaveWindow(event: PointerEvent) {
125
+ if (!event.relatedTarget) hide()
126
+ }
127
+
128
+ function handleKeyDown(event: KeyboardEvent) {
129
+ if (event.key === 'Escape') hide()
130
+ }
131
+
132
+ document.addEventListener('pointerover', handleEnter)
133
+ document.addEventListener('pointerout', handleLeaveWindow)
134
+ document.addEventListener('focusin', handleEnter)
135
+ document.addEventListener('pointerdown', hide)
136
+ document.addEventListener('focusout', hide)
137
+ document.addEventListener('keydown', handleKeyDown)
138
+ // Captura: rolagem em qualquer container interno invalida a posição já medida.
139
+ window.addEventListener('scroll', hide, true)
140
+ window.addEventListener('resize', hide)
141
+
142
+ return () => {
143
+ cancelPending()
144
+ document.removeEventListener('pointerover', handleEnter)
145
+ document.removeEventListener('pointerout', handleLeaveWindow)
146
+ document.removeEventListener('focusin', handleEnter)
147
+ document.removeEventListener('pointerdown', hide)
148
+ document.removeEventListener('focusout', hide)
149
+ document.removeEventListener('keydown', handleKeyDown)
150
+ window.removeEventListener('scroll', hide, true)
151
+ window.removeEventListener('resize', hide)
152
+ }
153
+ }, [isOwner])
154
+
155
+ if (!tooltip) return null
156
+
157
+ return createPortal(
158
+ <div
159
+ role="tooltip"
160
+ className={`cv-tooltip cv-tooltip--${tooltip.placement}`}
161
+ style={{ left: tooltip.left, top: tooltip.top }}
162
+ >
163
+ {tooltip.text}
164
+ </div>,
165
+ document.body,
166
+ )
167
+ }
package/src/Wallpaper.tsx CHANGED
@@ -8,7 +8,7 @@
8
8
  * A classe `cv-wallpaper` continua no elemento para quem já sobrescreve por CSS.
9
9
  */
10
10
 
11
- import type { CSSProperties, ReactNode } from 'react'
11
+ import { forwardRef, type CSSProperties, type ReactNode, type UIEvent } from 'react'
12
12
 
13
13
  import { cn } from './lib/cn'
14
14
  import { useIsDarkTheme } from './useDarkMode'
@@ -45,17 +45,31 @@ export interface ConversationWallpaperProps {
45
45
  className?: string
46
46
  /** Ajusta ou substitui o fundo padrão — para produto com identidade visual própria. */
47
47
  style?: CSSProperties
48
+ /**
49
+ * Rolagem da área de mensagens. Par do `ref`: é este elemento que rola, então é aqui que
50
+ * `useScrollToLatestMessage` observa a posição para saber se o operador está acompanhando o fim.
51
+ */
52
+ onScroll?: (event: UIEvent<HTMLDivElement>) => void
48
53
  }
49
54
 
50
- export function ConversationWallpaper({ children, className, style }: ConversationWallpaperProps) {
51
- const isDark = useIsDarkTheme()
52
-
53
- return (
54
- <div
55
- className={cn('cv-wallpaper', className)}
56
- style={{ ...(isDark ? DARK_WALLPAPER : LIGHT_WALLPAPER), ...style }}
57
- >
58
- {children}
59
- </div>
60
- )
61
- }
55
+ /**
56
+ * `forwardRef` porque quem controla a rolagem é de fora — o hook precisa do elemento para saltar
57
+ * até a última mensagem. `forwardRef` e não `ref` como prop: o pacote suporta React 18, onde
58
+ * ref-como-prop ainda não existe.
59
+ */
60
+ export const ConversationWallpaper = forwardRef<HTMLDivElement, ConversationWallpaperProps>(
61
+ function ConversationWallpaper({ children, className, style, onScroll }, ref) {
62
+ const isDark = useIsDarkTheme()
63
+
64
+ return (
65
+ <div
66
+ ref={ref}
67
+ onScroll={onScroll}
68
+ className={cn('cv-wallpaper', className)}
69
+ style={{ ...(isDark ? DARK_WALLPAPER : LIGHT_WALLPAPER), ...style }}
70
+ >
71
+ {children}
72
+ </div>
73
+ )
74
+ },
75
+ )
@@ -117,13 +117,13 @@ export function WhatsAppMessageEditor({
117
117
  return (
118
118
  <div>
119
119
  <div className="flex flex-wrap items-center gap-1.5 mb-2">
120
- <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title={editorLabels.boldHint} aria-label={editorLabels.bold}>
120
+ <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.boldHint} aria-label={editorLabels.bold}>
121
121
  <Bold size={15} />
122
122
  </button>
123
- <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title={editorLabels.italicHint} aria-label={editorLabels.italic}>
123
+ <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.italicHint} aria-label={editorLabels.italic}>
124
124
  <Italic size={15} />
125
125
  </button>
126
- <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title={editorLabels.strikethroughHint} aria-label={editorLabels.strikethrough}>
126
+ <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.strikethroughHint} aria-label={editorLabels.strikethrough}>
127
127
  <Strikethrough size={15} />
128
128
  </button>
129
129
  {placeholders.length > 0 && (
@@ -135,7 +135,7 @@ export function WhatsAppMessageEditor({
135
135
  type="button"
136
136
  onClick={() => insertAtCursor(token)}
137
137
  className="inline-flex items-center h-8 px-2 rounded-lg border border-gray-200 dark:border-gray-600 text-xs text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/40 transition-colors"
138
- title={editorLabels.insertPlaceholder(token)}
138
+ data-cv-tooltip={editorLabels.insertPlaceholder(token)} aria-label={editorLabels.insertPlaceholder(token)}
139
139
  >
140
140
  {token}
141
141
  </button>
@@ -155,9 +155,12 @@ export function WhatsAppMessageEditor({
155
155
 
156
156
  <div className="mt-2">
157
157
  <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{previewLabel}</p>
158
- <div className="rounded-xl bg-[#e5ded8] dark:bg-gray-900 p-3">
159
- <div className="inline-block max-w-full rounded-lg rounded-tl-none bg-white dark:bg-gray-800 shadow-sm px-3 py-2 text-sm text-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words">
160
- {value.trim() ? renderWhatsApp(value) : <span className="text-gray-400 dark:text-gray-500">{emptyPreviewText}</span>}
158
+ <div className="rounded-2xl bg-[#e5ddd5] dark:bg-[#1a1a2e] p-3">
159
+ <div className="flex justify-end ml-auto" style={{ maxWidth: '85%' }}>
160
+ <div className="bg-[#dcf8c6] dark:bg-[#1b5e20] rounded-lg rounded-tr-none shadow-sm px-3 py-2 text-sm text-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words">
161
+ {value.trim() ? renderWhatsApp(value) : <span className="text-gray-400 dark:text-gray-500">{emptyPreviewText}</span>}
162
+ <p className="text-gray-400 dark:text-gray-400 mt-1 text-right text-[9px]">12:00 ✓✓</p>
163
+ </div>
161
164
  </div>
162
165
  </div>
163
166
  </div>
@@ -6,7 +6,10 @@
6
6
  * cumpre. O caminho que resta — template — fica visível na mesma altura da tela.
7
7
  */
8
8
 
9
+ import { AlarmClock, Send } from 'lucide-react'
10
+
9
11
  import { CONVERSATION_WINDOW, type ConversationWindow } from './conversationWindow'
12
+ import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from './icon.constant'
10
13
  import { cn } from './lib/cn'
11
14
 
12
15
  export interface WindowExpiredNoticeLabels {
@@ -15,10 +18,11 @@ export interface WindowExpiredNoticeLabels {
15
18
  sendTemplate: string
16
19
  }
17
20
 
21
+ // Sem emoji no rótulo: o ícone vem ao lado, da mesma biblioteca do resto da tela.
18
22
  export const DEFAULT_WINDOW_EXPIRED_LABELS: WindowExpiredNoticeLabels = {
19
- title: 'Janela de 24h expirada — o WhatsApp não aceita mensagens livres.',
23
+ title: 'Janela de 24h expirada — o WhatsApp não aceita mensagens livres.',
20
24
  description: 'Envie uma mensagem de template para reabrir a conversa com o cliente.',
21
- sendTemplate: '📨 Enviar Template (HSM)',
25
+ sendTemplate: 'Enviar Template (HSM)',
22
26
  }
23
27
 
24
28
  export interface WindowExpiredNoticeProps {
@@ -38,10 +42,14 @@ export function WindowExpiredNotice({
38
42
 
39
43
  return (
40
44
  <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>
45
+ <p className="flex items-center gap-2 font-medium text-yellow-900 dark:text-yellow-200">
46
+ <AlarmClock size={ICON_SIZE_INLINE} aria-hidden="true" className="shrink-0" />
47
+ {labels.title}
48
+ </p>
42
49
  <p className="text-yellow-800 dark:text-yellow-300">{labels.description}</p>
43
50
  {onSendTemplate ? (
44
- <button type="button" onClick={onSendTemplate} disabled={disabled} className="cv-header-action mt-2">
51
+ <button data-cv-tooltip={labels.sendTemplate} aria-label={labels.sendTemplate} type="button" onClick={onSendTemplate} disabled={disabled} className="cv-header-action mt-2">
52
+ <Send size={ICON_SIZE_ACTION} aria-hidden="true" />
45
53
  {labels.sendTemplate}
46
54
  </button>
47
55
  ) : null}
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from 'bun:test'
2
2
 
3
3
  import { resolveRecordingFormat } from './AudioRecorderButton'
4
- import { DEFAULT_ACCEPTED_FILE_TYPES } from '../MessageComposer'
4
+ import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
5
5
 
6
6
  const originalMediaRecorder = (globalThis as Record<string, unknown>).MediaRecorder
7
7
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * O único teste que lê o `dist` em vez do fonte.
5
+ *
6
+ * Existe porque o `notification-ui@rc.1` saiu com 19 testes verdes e não renderizava. Dois defeitos,
7
+ * os dois invisíveis para quem importa o fonte:
8
+ *
9
+ * 1. `jsx: react-jsx` chegava ao tsup por um tsconfig com `extends`, e o esbuild NÃO segue `extends`
10
+ * para essa opção. O bundle saiu com `React.createElement` sem `React` no escopo, e o produto
11
+ * quebrou com `ReferenceError: React is not defined`.
12
+ * 2. `splitting: false` com dois entrypoints duplicou o módulo de contexto. O provider de um bundle
13
+ * não era o mesmo objeto do consumidor no outro, e o hook acusava "usado fora do provider" estando
14
+ * dentro de um.
15
+ *
16
+ * Nenhum teste de fonte pega isso: eles importam `./index`, não `dist`. Rode depois do build.
17
+ */
18
+
19
+ import { describe, expect, it } from 'bun:test'
20
+
21
+ const DIST = `${import.meta.dir}/../dist`
22
+
23
+ async function distText(file: string): Promise<string> {
24
+ const handle = Bun.file(`${DIST}/${file}`)
25
+ expect(await handle.exists(), `${file} não existe — rode \`bun run build\` antes`).toBe(true)
26
+ return handle.text()
27
+ }
28
+
29
+ describe('transform de JSX', () => {
30
+ it('usa o runtime automático, e não React.createElement', async () => {
31
+ for (const file of ['index.js', 'flows/index.js', 'preview/index.js']) {
32
+ const content = await distText(file)
33
+
34
+ expect(content, `${file} com createElement`).not.toContain('React.createElement')
35
+ expect(content, `${file} sem jsx-runtime`).toContain('react/jsx-runtime')
36
+ }
37
+ })
38
+ })
39
+
40
+ describe('divisão de código entre entrypoints', () => {
41
+ it('os entrypoints compartilham chunk em vez de duplicar módulo', async () => {
42
+ // Sem `splitting: true`, cada entrypoint carrega a própria cópia dos módulos comuns — e um
43
+ // contexto do React duplicado deixa de ser o mesmo objeto entre provider e consumidor.
44
+ const chunks = [...new Bun.Glob('chunk-*.js').scanSync({ cwd: DIST })]
45
+
46
+ expect(chunks.length, 'nenhum chunk compartilhado gerado').toBeGreaterThan(0)
47
+ })
48
+ })
49
+
50
+ describe('telas compostas chegam ao pacote publicado', () => {
51
+ it('o subpath raiz entrega MessagesWorkspace, no js e nos tipos', async () => {
52
+ expect(await distText('index.js')).toContain('MessagesWorkspace')
53
+ expect(await distText('index.d.ts')).toContain('MessagesWorkspace')
54
+ })
55
+
56
+ it('o subpath /flows entrega FlowsWorkspace, no js e nos tipos', async () => {
57
+ // Export declarado no `index.ts` e ausente do `dist` é o modo de falhar mais barato de cometer e
58
+ // mais caro de descobrir: só aparece no produto, depois de publicar.
59
+ expect(await distText('flows/index.js')).toContain('FlowsWorkspace')
60
+ expect(await distText('flows/index.d.ts')).toContain('FlowsWorkspace')
61
+ })
62
+
63
+ it('o CSS publicado traz as classes que as telas consomem', async () => {
64
+ const css = await distText('styles.css')
65
+
66
+ // A inbox depende destas classes do pacote (o editor de fluxos e a tela de mensagens estilizam por
67
+ // utilitários do host). Publicar sem elas deixa a tela montada e sem layout — e nada falha.
68
+ expect(css).toContain('.cv-workspace')
69
+ expect(css).toContain('.cv-workspace-modal')
70
+ })
71
+ })
72
+
73
+ describe('o bundle do host não paga por @xyflow/react sem pedir', () => {
74
+ it('o subpath raiz não puxa o xyflow', async () => {
75
+ // É o motivo de o editor viver em `/flows`: quem só usa a inbox não carrega a biblioteca de
76
+ // canvas, que é a maior dependência do pacote.
77
+ expect(await distText('index.js')).not.toContain('@xyflow/react')
78
+ })
79
+ })
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Aparência da barra de composição, compartilhada pelo `MessageComposer` e pelo
3
+ * `RichMessageComposer`. São a mesma superfície na tela: divergir aqui é o que fazia a barra rica
4
+ * aparecer branca sobre o fundo branco da página, com os atalhos flutuando sem faixa própria.
5
+ */
6
+
7
+ /** Faixa cinza de largura cheia; o campo de texto dentro dela é que arredonda — ordem do WhatsApp. */
8
+ export const COMPOSER_BAR_CLASS = 'cv-composer-bar'
9
+
10
+ /** Botão redondo da barra: formatação, emoji, variáveis e anexo têm o mesmo peso visual. */
11
+ export const COMPOSER_TOOL_BUTTON_CLASS =
12
+ 'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full transition-colors hover:bg-teal-50 hover:text-teal-600 dark:hover:bg-teal-900/30 dark:hover:text-teal-400'
13
+
14
+ /** Formatação em vigor no cursor. Sem isto o operador não sabe se o próximo caractere sai em negrito. */
15
+ export const COMPOSER_TOOL_BUTTON_ACTIVE_CLASS = 'bg-teal-50 text-teal-600 dark:bg-teal-900/40 dark:text-teal-400'
16
+
17
+ export const COMPOSER_TOOL_BUTTON_IDLE_CLASS = 'text-gray-400 dark:text-gray-500'
18
+
19
+ /** Marcação do monoespaçado dentro do campo — a mesma que o `waToHTML` escreve, para o trecho não
20
+ * mudar de cara ao recarregar um rascunho. */
21
+ export const COMPOSER_MONOSPACE_CLASS = 'bg-black/5 dark:bg-white/10 rounded px-0.5 font-mono text-sm'
22
+
23
+ /**
24
+ * Abaixo desta largura de barra (em px) os quatro botões de formatação recolhem num único botão com
25
+ * popover. Com todos abertos, sobram ~200px de barra para os oito controles e o campo de texto fica
26
+ * mais estreito que o próprio placeholder. É a largura da barra, não da janela: quem estreita a
27
+ * coluna é a prévia do simulador ao lado, com a janela intacta.
28
+ */
29
+ export const COMPOSER_COMPACT_WIDTH = 480
30
+
31
+ /** Atalho de resposta rápida: pílula branca, que só tem contraste porque a faixa é cinza. */
32
+ export const QUICK_REPLY_PILL_CLASS =
33
+ 'whitespace-nowrap rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 shadow-sm transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400'
@@ -62,4 +62,61 @@ describe('buildTranscriptFilename', () => {
62
62
  `conversa-${NUMBER}-2026-07-27.txt`,
63
63
  )
64
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
+ })
65
122
  })
@@ -33,16 +33,41 @@ export function buildTranscriptText(params: BuildTranscriptTextParams): string {
33
33
  ]
34
34
 
35
35
  const lines = params.messages.map((message) => {
36
- const stamp = new Date(message.timestamp).toLocaleString('pt-BR')
37
36
  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}`
37
+ return `[${formatStamp(message.timestamp)}] ${author}: ${bodyOf(message)}`
41
38
  })
42
39
 
43
40
  return [...header, ...lines].join('\n')
44
41
  }
45
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
+
46
71
  export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
47
72
  const stamp = generatedAt.toISOString().slice(0, 10)
48
73
  return `conversa-${whatsappNumber}-${stamp}.txt`
@@ -22,12 +22,14 @@ export const CONVERSATION_WINDOW = {
22
22
  } as const
23
23
  export type ConversationWindow = (typeof CONVERSATION_WINDOW)[keyof typeof CONVERSATION_WINDOW]
24
24
 
25
+ // O tom pinta o próprio chip, em vez de uma bolinha ao lado do rótulo: a bolinha somava ~14px por
26
+ // filtro e era o que fazia a fila de cinco quebrar em duas linhas na coluna da lista.
25
27
  export const WINDOW_FILTERS = [
26
- { value: CONVERSATION_WINDOW.ALL, label: 'Todas', dotClass: '' },
27
- { value: CONVERSATION_WINDOW.FRESH, label: '<12h', dotClass: 'bg-green-500' },
28
- { value: CONVERSATION_WINDOW.WARNING, label: '12-21h', dotClass: 'bg-yellow-500' },
29
- { value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h', dotClass: 'bg-red-500' },
30
- { value: CONVERSATION_WINDOW.EXPIRED, label: '>24h', dotClass: 'bg-gray-400' },
28
+ { value: CONVERSATION_WINDOW.ALL, label: 'Todas', tone: '' },
29
+ { value: CONVERSATION_WINDOW.FRESH, label: '<12h', tone: 'fresh' },
30
+ { value: CONVERSATION_WINDOW.WARNING, label: '12-21h', tone: 'warning' },
31
+ { value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h', tone: 'critical' },
32
+ { value: CONVERSATION_WINDOW.EXPIRED, label: '>24h', tone: 'expired' },
31
33
  ] as const
32
34
 
33
35
  // Proporções da janela: 12h e 21h numa janela de 24h, mas expressas como fração para acompanharem
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Guarda o rótulo de tipo da bolha de documento.
3
+ *
4
+ * O defeito que motivou: `mimeType.split('/')[1].toUpperCase()` transformava um `.docx` em
5
+ * `VND.OPENXMLFORMATS-OFFICEDOCUMENT.WORDPROCESSINGML.DOCUMENT` — 58 caracteres numa linha que não
6
+ * quebra, esticando a bolha até empurrar a coluna da conversa e estourar o layout de três painéis.
7
+ */
8
+
9
+ import { describe, expect, it } from 'bun:test'
10
+
11
+ import { documentTypeLabel } from './MediaRenderer'
12
+
13
+ describe('documentTypeLabel', () => {
14
+ // O caso que quebrou a tela.
15
+ it('usa a extensão do arquivo em vez do subtipo gigante do Office', () => {
16
+ const rotulo = documentTypeLabel(
17
+ 'contrato.docx',
18
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
19
+ )
20
+
21
+ expect(rotulo).toBe('DOCX')
22
+ })
23
+
24
+ it('serve para os tipos curtos também', () => {
25
+ expect(documentTypeLabel('nota-fiscal.pdf', 'application/pdf')).toBe('PDF')
26
+ expect(documentTypeLabel('lista.txt', 'text/plain')).toBe('TXT')
27
+ })
28
+
29
+ // Áudio e sticker chegam sem nome de arquivo: o rótulo vem do mimeType, ainda curto.
30
+ it('cai no sufixo do subtipo quando não há nome', () => {
31
+ expect(documentTypeLabel(undefined, 'application/vnd.ms-excel')).toBe('MS-EXCEL')
32
+ expect(documentTypeLabel(undefined, 'application/pdf')).toBe('PDF')
33
+ })
34
+
35
+ it('descarta parâmetro do mimeType', () => {
36
+ expect(documentTypeLabel(undefined, 'audio/ogg; codecs=opus')).toBe('OGG')
37
+ })
38
+
39
+ // Nome sem ponto não tem extensão — o id da mídia, por exemplo.
40
+ it('não confunde nome sem extensão com extensão', () => {
41
+ expect(documentTypeLabel('seed-media-03', 'application/zip')).toBe('ZIP')
42
+ })
43
+
44
+ it('tem rótulo para o caso sem nome e sem tipo', () => {
45
+ expect(documentTypeLabel()).toBe('FILE')
46
+ })
47
+
48
+ // Teto de tamanho: nenhum rótulo pode voltar a esticar a bolha.
49
+ it('nunca passa de 12 caracteres', () => {
50
+ const longo = documentTypeLabel(
51
+ undefined,
52
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
53
+ )
54
+
55
+ expect(longo.length).toBeLessThanOrEqual(12)
56
+ })
57
+ })