@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41

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 (149) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2389 -678
  7. package/dist/index.d.ts +1171 -42
  8. package/dist/index.js +3755 -843
  9. package/dist/preview/index.d.ts +157 -42
  10. package/dist/preview/index.js +772 -191
  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 +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +382 -0
  29. package/src/EmojiPicker.tsx +70 -55
  30. package/src/FileIcon.test.ts +83 -0
  31. package/src/FileIcon.tsx +88 -11
  32. package/src/InteractiveMessage.test.tsx +41 -0
  33. package/src/InteractiveMessage.tsx +146 -0
  34. package/src/Lightbox.tsx +18 -3
  35. package/src/MediaRenderer.tsx +92 -16
  36. package/src/MessageBubble.test.tsx +41 -0
  37. package/src/MessageBubble.tsx +75 -6
  38. package/src/MessageComposer.test.tsx +35 -0
  39. package/src/MessageComposer.tsx +155 -19
  40. package/src/RichMessageComposer.test.tsx +113 -0
  41. package/src/RichMessageComposer.tsx +551 -0
  42. package/src/SimpleEmojiPicker.tsx +5 -3
  43. package/src/StatusTicks.tsx +1 -1
  44. package/src/Toast.tsx +4 -0
  45. package/src/Tooltip.test.ts +42 -0
  46. package/src/Tooltip.tsx +167 -0
  47. package/src/Wallpaper.test.tsx +21 -0
  48. package/src/Wallpaper.tsx +67 -7
  49. package/src/WhatsAppMessageEditor.tsx +34 -7
  50. package/src/WindowExpiredNotice.tsx +12 -4
  51. package/src/audioRecorderFormat.test.ts +67 -0
  52. package/src/buildOutput.test.ts +79 -0
  53. package/src/composer.constant.ts +33 -0
  54. package/src/conversationTranscript.test.ts +57 -0
  55. package/src/conversationTranscript.ts +29 -4
  56. package/src/conversationWindow.ts +7 -5
  57. package/src/documentTypeLabel.test.ts +57 -0
  58. package/src/documents/DocumentsWorkspace.tsx +550 -0
  59. package/src/documents/index.ts +8 -0
  60. package/src/documents/labels.ts +92 -0
  61. package/src/emojiCatalog.test.ts +35 -0
  62. package/src/emojiCatalog.ts +189 -0
  63. package/src/flows/FlowConnectionEdge.tsx +104 -0
  64. package/src/flows/FlowGroupHeader.tsx +12 -2
  65. package/src/flows/FlowLegend.tsx +125 -0
  66. package/src/flows/FlowMapCanvas.tsx +15 -12
  67. package/src/flows/FlowMapNode.tsx +4 -1
  68. package/src/flows/FlowNodeCard.tsx +219 -34
  69. package/src/flows/FlowNodePanel.tsx +153 -39
  70. package/src/flows/FlowPalette.tsx +106 -69
  71. package/src/flows/FlowPortalNode.tsx +1 -1
  72. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  73. package/src/flows/FlowsWorkspace.tsx +1219 -0
  74. package/src/flows/flowCanvasModel.test.ts +456 -0
  75. package/src/flows/flowCanvasModel.ts +378 -0
  76. package/src/flows/flowEditorOps.test.ts +276 -0
  77. package/src/flows/flowEditorOps.ts +202 -0
  78. package/src/flows/flowGraph.ts +78 -53
  79. package/src/flows/index.ts +51 -2
  80. package/src/flows/labels.ts +180 -0
  81. package/src/flows/workspaceContract.test.ts +126 -0
  82. package/src/hooks/useContainerWidth.ts +35 -0
  83. package/src/hooks/useConversationActions.ts +56 -0
  84. package/src/hooks/useConversationDocuments.ts +11 -7
  85. package/src/hooks/useConversationList.ts +15 -9
  86. package/src/hooks/useConversationMessages.ts +2 -2
  87. package/src/hooks/useConversationRealtime.ts +10 -8
  88. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  89. package/src/hooks/useUrlFilterState.ts +107 -0
  90. package/src/icon.constant.ts +12 -0
  91. package/src/index.ts +129 -13
  92. package/src/lib/cn.test.ts +29 -0
  93. package/src/lib/composer-formatting.test.ts +78 -0
  94. package/src/lib/composer-formatting.ts +145 -0
  95. package/src/lib/createMediaUrlResolver.ts +33 -0
  96. package/src/lib/paginated.test.ts +33 -0
  97. package/src/lib/paginated.ts +26 -0
  98. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  99. package/src/lib/whatsapp-formatting.tsx +28 -3
  100. package/src/listing/index.tsx +202 -0
  101. package/src/pagination.constant.ts +10 -0
  102. package/src/preview/ConversationPreview.tsx +225 -17
  103. package/src/preview/ConversationSimulatorClient.ts +143 -0
  104. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  105. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  106. package/src/preview/MediaTypesPreview.tsx +87 -0
  107. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  108. package/src/preview/createMockConversationsApi.ts +175 -15
  109. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  110. package/src/preview/createPreviewBridgeClient.ts +124 -0
  111. package/src/preview/createPreviewMediaUploader.ts +82 -0
  112. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  113. package/src/preview/createPreviewWebhookClient.ts +127 -4
  114. package/src/preview/index.ts +51 -3
  115. package/src/preview/mediaTypeOf.test.ts +15 -0
  116. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  117. package/src/preview/preview.test.ts +5 -3
  118. package/src/preview/previewFileSamples.test.ts +151 -0
  119. package/src/preview/previewFileSamples.ts +74 -0
  120. package/src/preview/previewFixtures.ts +288 -1
  121. package/src/preview/previewMediaSource.test.ts +62 -0
  122. package/src/preview/previewMediaSource.ts +91 -0
  123. package/src/preview/previewMediaUploader.test.ts +61 -0
  124. package/src/providers/ConversationsProvider.tsx +8 -6
  125. package/src/providers/types.ts +185 -10
  126. package/src/quickReply.test.ts +58 -0
  127. package/src/settings/MessagesWorkspace.tsx +571 -0
  128. package/src/settings/TopicsForm.tsx +2 -0
  129. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  130. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  131. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  132. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  133. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  134. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  135. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  136. package/src/styles.css +858 -0
  137. package/src/types.ts +64 -1
  138. package/src/useWaitingNotifications.ts +74 -29
  139. package/src/workspace/BulkTemplateModal.tsx +132 -0
  140. package/src/workspace/ConversationPane.tsx +432 -0
  141. package/src/workspace/ConversationsInboxList.tsx +194 -0
  142. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  143. package/src/workspace/index.ts +17 -0
  144. package/src/workspace/labels.test.ts +17 -0
  145. package/src/workspace/labels.ts +85 -0
  146. package/src/workspace/useConversationsInbox.ts +332 -0
  147. package/dist/chunk-4R6Y43DQ.js +0 -726
  148. package/dist/chunk-NV2RZ5KT.js +0 -56
  149. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -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
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ConversationWallpaper } from './Wallpaper'
5
+
6
+ describe('ConversationWallpaper', () => {
7
+ it('desenha o fundo sozinho, sem depender do styles.css do host', () => {
8
+ const markup = renderToStaticMarkup(<ConversationWallpaper />)
9
+
10
+ expect(markup).toContain('background-color:#efeae2')
11
+ expect(markup).toContain('background-image:url(&quot;data:image/svg+xml,')
12
+ expect(markup).toContain('cv-wallpaper')
13
+ })
14
+
15
+ it('deixa o produto sobrescrever o fundo por style', () => {
16
+ const markup = renderToStaticMarkup(<ConversationWallpaper style={{ backgroundColor: '#123456' }} />)
17
+
18
+ expect(markup).toContain('background-color:#123456')
19
+ expect(markup).not.toContain('background-color:#efeae2')
20
+ })
21
+ })
package/src/Wallpaper.tsx CHANGED
@@ -1,15 +1,75 @@
1
- import type { ReactNode } from 'react'
1
+ /**
2
+ * Fundo da conversa — a textura clara/escura do WhatsApp.
3
+ *
4
+ * O fundo vem embutido, e não da folha de estilo do pacote: `styles.css` é um import opcional que o
5
+ * host precisa lembrar de fazer, e esquecê-lo deixava a conversa sobre branco liso, sem erro nenhum
6
+ * para denunciar o problema. Fundo é identidade do componente, não tema do host.
7
+ *
8
+ * A classe `cv-wallpaper` continua no elemento para quem já sobrescreve por CSS.
9
+ */
10
+
11
+ import { forwardRef, type CSSProperties, type ReactNode, type UIEvent } from 'react'
2
12
 
3
13
  import { cn } from './lib/cn'
14
+ import { useIsDarkTheme } from './useDarkMode'
15
+
16
+ /** Textura do WhatsApp: rabiscos esparsos que se repetem a cada 100px. */
17
+ function doodlePattern(strokeColor: string, opacity: number): string {
18
+ const svg =
19
+ `<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'>` +
20
+ `<g fill='none' stroke='${strokeColor}' stroke-width='1.2' opacity='${opacity}'>` +
21
+ `<circle cx='15' cy='15' r='3'/><path d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/>` +
22
+ `<circle cx='70' cy='28' r='2'/><path d='M18 55 l6 6 m-6 0 l6 -6'/>` +
23
+ `<circle cx='55' cy='68' r='2.5'/><path d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/>` +
24
+ `<circle cx='8' cy='85' r='2'/><path d='M65 90 l5 5 m-5 0 l5 -5'/>` +
25
+ `</g></svg>`
26
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
27
+ }
28
+
29
+ const LIGHT_WALLPAPER: CSSProperties = {
30
+ backgroundColor: '#efeae2',
31
+ backgroundImage: doodlePattern('#d7cfc0', 0.7),
32
+ backgroundRepeat: 'repeat',
33
+ backgroundSize: '100px 100px',
34
+ }
35
+
36
+ const DARK_WALLPAPER: CSSProperties = {
37
+ backgroundColor: '#0b141a',
38
+ backgroundImage: doodlePattern('#19232a', 0.9),
39
+ backgroundRepeat: 'repeat',
40
+ backgroundSize: '100px 100px',
41
+ }
4
42
 
5
43
  export interface ConversationWallpaperProps {
6
44
  children?: ReactNode
7
45
  className?: string
46
+ /** Ajusta ou substitui o fundo padrão — para produto com identidade visual própria. */
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
8
53
  }
9
54
 
10
- // Requer o import de '@adatechnology/conversations-ui/styles.css' uma vez no host —
11
- // a classe `.cv-wallpaper` (e sua variante `.dark`) vive nesse stylesheet, não em CSS-in-JS,
12
- // para não depender da configuração de Tailwind do consumidor.
13
- export function ConversationWallpaper({ children, className }: ConversationWallpaperProps) {
14
- return <div className={cn('cv-wallpaper', className)}>{children}</div>
15
- }
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
+ )
@@ -1,7 +1,29 @@
1
1
  import { useRef, type ReactNode } from 'react'
2
2
  import { Bold, Italic, Strikethrough } from 'lucide-react'
3
3
 
4
+ export interface WhatsAppMessageEditorLabels {
5
+ bold: string
6
+ /** Tooltip da negrito — traz a sintaxe do WhatsApp junto, por isso é separado do `aria-label`. */
7
+ boldHint: string
8
+ italic: string
9
+ italicHint: string
10
+ strikethrough: string
11
+ strikethroughHint: string
12
+ insertPlaceholder: (token: string) => string
13
+ }
14
+
15
+ export const DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS: WhatsAppMessageEditorLabels = {
16
+ bold: 'Negrito',
17
+ boldHint: 'Negrito (*texto*)',
18
+ italic: 'Itálico',
19
+ italicHint: 'Itálico (_texto_)',
20
+ strikethrough: 'Tachado',
21
+ strikethroughHint: 'Tachado (~texto~)',
22
+ insertPlaceholder: (token: string) => `Inserir ${token}`,
23
+ }
24
+
4
25
  export interface WhatsAppMessageEditorProps {
26
+ labels?: Partial<WhatsAppMessageEditorLabels>
5
27
  value: string
6
28
  onChange: (value: string) => void
7
29
  placeholder?: string
@@ -56,7 +78,9 @@ export function WhatsAppMessageEditor({
56
78
  rows = 4,
57
79
  previewLabel = 'Prévia (como aparece no WhatsApp)',
58
80
  emptyPreviewText = 'Sua mensagem aparecerá aqui…',
81
+ labels,
59
82
  }: WhatsAppMessageEditorProps) {
83
+ const editorLabels = { ...DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, ...labels }
60
84
  const textareaRef = useRef<HTMLTextAreaElement>(null)
61
85
 
62
86
  function wrapSelection(marker: string): void {
@@ -93,13 +117,13 @@ export function WhatsAppMessageEditor({
93
117
  return (
94
118
  <div>
95
119
  <div className="flex flex-wrap items-center gap-1.5 mb-2">
96
- <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title="Negrito (*texto*)" aria-label="Negrito">
120
+ <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.boldHint} aria-label={editorLabels.bold}>
97
121
  <Bold size={15} />
98
122
  </button>
99
- <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title="Itálico (_texto_)" aria-label="Itálico">
123
+ <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.italicHint} aria-label={editorLabels.italic}>
100
124
  <Italic size={15} />
101
125
  </button>
102
- <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title="Tachado (~texto~)" aria-label="Tachado">
126
+ <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.strikethroughHint} aria-label={editorLabels.strikethrough}>
103
127
  <Strikethrough size={15} />
104
128
  </button>
105
129
  {placeholders.length > 0 && (
@@ -111,7 +135,7 @@ export function WhatsAppMessageEditor({
111
135
  type="button"
112
136
  onClick={() => insertAtCursor(token)}
113
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"
114
- title={`Inserir ${token}`}
138
+ data-cv-tooltip={editorLabels.insertPlaceholder(token)} aria-label={editorLabels.insertPlaceholder(token)}
115
139
  >
116
140
  {token}
117
141
  </button>
@@ -131,9 +155,12 @@ export function WhatsAppMessageEditor({
131
155
 
132
156
  <div className="mt-2">
133
157
  <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{previewLabel}</p>
134
- <div className="rounded-xl bg-[#e5ded8] dark:bg-gray-900 p-3">
135
- <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">
136
- {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>
137
164
  </div>
138
165
  </div>
139
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}
@@ -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,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
  })