@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1
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.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
package/src/Tooltip.tsx
ADDED
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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-
|
|
159
|
-
<div className="
|
|
160
|
-
|
|
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: '
|
|
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: '
|
|
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">
|
|
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 '
|
|
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
|
-
|
|
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',
|
|
27
|
-
{ value: CONVERSATION_WINDOW.FRESH, label: '<12h',
|
|
28
|
-
{ value: CONVERSATION_WINDOW.WARNING, label: '12-21h',
|
|
29
|
-
{ value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h',
|
|
30
|
-
{ value: CONVERSATION_WINDOW.EXPIRED, label: '>24h',
|
|
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
|
+
})
|