@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.20
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/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-OIDAIVCH.js +1985 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +729 -136
- package/dist/index.js +1418 -1104
- package/dist/preview/index.d.ts +373 -0
- package/dist/preview/index.js +1247 -0
- package/dist/styles.css +198 -0
- package/dist/types-O7kMP1Yn.d.ts +416 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +127 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +64 -21
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +146 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +54 -6
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +129 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +325 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +88 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +131 -0
- package/src/preview/index.ts +61 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +163 -11
- package/src/quickReply.test.ts +58 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +46 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barra de composição do atendente: texto rico com a formatação do WhatsApp, respostas rápidas e
|
|
3
|
+
* anexos.
|
|
4
|
+
*
|
|
5
|
+
* Distinto do `MessageComposer`, que é um `textarea` simples: aqui o operador vê o negrito em
|
|
6
|
+
* negrito enquanto escreve, e não os asteriscos. Quem só precisa de uma caixa de texto continua no
|
|
7
|
+
* `MessageComposer`.
|
|
8
|
+
*
|
|
9
|
+
* Nada aqui sabe de produto. O que aparece na barra é decidido por `toolbar`, o texto de cada dica
|
|
10
|
+
* por `tooltips`, e as respostas rápidas chegam prontas por `quickReplies` — rótulo e conteúdo são
|
|
11
|
+
* do host, a mecânica é daqui.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type KeyboardEvent, type ChangeEvent, type FormEvent, type ReactNode } from 'react'
|
|
15
|
+
import { Bold, Italic, Strikethrough, Code, Paperclip, SendHorizonal, Braces } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
import { cn } from './lib/cn'
|
|
18
|
+
import { htmlToWA, waToHTML } from './lib/whatsapp-formatting'
|
|
19
|
+
import { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
20
|
+
import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
21
|
+
|
|
22
|
+
/** Cada ação que a barra sabe oferecer. `toolbar` decide quais delas aparecem. */
|
|
23
|
+
export const RICH_COMPOSER_ACTION = {
|
|
24
|
+
BOLD: 'bold',
|
|
25
|
+
ITALIC: 'italic',
|
|
26
|
+
STRIKETHROUGH: 'strikethrough',
|
|
27
|
+
MONOSPACE: 'monospace',
|
|
28
|
+
EMOJI: 'emoji',
|
|
29
|
+
ATTACH: 'attach',
|
|
30
|
+
VARIABLES: 'variables',
|
|
31
|
+
} as const
|
|
32
|
+
export type RichComposerAction = (typeof RICH_COMPOSER_ACTION)[keyof typeof RICH_COMPOSER_ACTION]
|
|
33
|
+
|
|
34
|
+
/** Texto das dicas. O host troca só o que quiser; o resto fica no padrão. */
|
|
35
|
+
export interface RichComposerTooltips {
|
|
36
|
+
bold: string
|
|
37
|
+
italic: string
|
|
38
|
+
strikethrough: string
|
|
39
|
+
monospace: string
|
|
40
|
+
emoji: string
|
|
41
|
+
attach: string
|
|
42
|
+
send: string
|
|
43
|
+
variables: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
|
|
47
|
+
bold: 'Negrito',
|
|
48
|
+
italic: 'Itálico',
|
|
49
|
+
strikethrough: 'Tachado',
|
|
50
|
+
monospace: 'Monoespaçado',
|
|
51
|
+
emoji: 'Emojis',
|
|
52
|
+
attach: 'Anexar arquivo',
|
|
53
|
+
send: 'Enviar',
|
|
54
|
+
variables: 'Variáveis disponíveis',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Um valor que o operador pode inserir no texto sem digitar. `value` é o que entra no campo — pode
|
|
59
|
+
* ser o dado já resolvido ("Anderson") ou um marcador a resolver depois ("{{nome}}"): quem decide é
|
|
60
|
+
* o host, porque só ele sabe se a substituição acontece aqui ou no envio.
|
|
61
|
+
*/
|
|
62
|
+
export interface RichComposerVariable {
|
|
63
|
+
id: string
|
|
64
|
+
label: string
|
|
65
|
+
value: string
|
|
66
|
+
/** Prévia do que será inserido, para o operador conferir antes de tocar. */
|
|
67
|
+
tooltip?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Uma resposta rápida: o que o operador lê no chip e o que cai no campo ao tocar nele. */
|
|
71
|
+
export interface RichComposerQuickReply {
|
|
72
|
+
id: string
|
|
73
|
+
label: string
|
|
74
|
+
text: string
|
|
75
|
+
/** Dica ao passar o mouse. Sem isto, o chip não tem `title` — rótulo curto já se explica. */
|
|
76
|
+
tooltip?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Cada formatação vira o elemento correspondente dentro do campo, não os asteriscos crus: o ponto
|
|
81
|
+
* do editor rico é o operador ver o negrito em negrito. A conversão para a notação do WhatsApp
|
|
82
|
+
* acontece na saída, no `htmlToWA`.
|
|
83
|
+
*/
|
|
84
|
+
const FORMATTING_ELEMENT = {
|
|
85
|
+
bold: 'strong',
|
|
86
|
+
italic: 'em',
|
|
87
|
+
strikethrough: 'del',
|
|
88
|
+
monospace: 'code',
|
|
89
|
+
} as const
|
|
90
|
+
|
|
91
|
+
const MONOSPACE_CLASS = 'bg-black/5 dark:bg-white/10 rounded px-0.5 font-mono text-sm'
|
|
92
|
+
|
|
93
|
+
/** Handle imperativo: `contentEditable` controlado pelo React perde o cursor a cada tecla. */
|
|
94
|
+
export interface RichMessageComposerHandle {
|
|
95
|
+
setContent: (text: string) => void
|
|
96
|
+
clear: () => void
|
|
97
|
+
focus: () => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RichMessageComposerProps {
|
|
101
|
+
/** Texto na notação do WhatsApp (`*negrito*`), não HTML. */
|
|
102
|
+
value: string
|
|
103
|
+
onChange: (value: string) => void
|
|
104
|
+
onSend: () => void
|
|
105
|
+
onAttachFiles?: (files: FileList) => void
|
|
106
|
+
quickReplies?: RichComposerQuickReply[]
|
|
107
|
+
/** Variáveis que o operador pode inserir no texto. Vazio ou ausente, o botão não aparece. */
|
|
108
|
+
variables?: RichComposerVariable[]
|
|
109
|
+
/**
|
|
110
|
+
* Quais ações aparecem. Ausente, todas aparecem — menos as que não têm como funcionar (anexo sem
|
|
111
|
+
* `onAttachFiles` some sozinho, porque botão que não faz nada é pior que botão nenhum).
|
|
112
|
+
*/
|
|
113
|
+
toolbar?: Partial<Record<RichComposerAction, boolean>>
|
|
114
|
+
tooltips?: Partial<RichComposerTooltips>
|
|
115
|
+
placeholder?: string
|
|
116
|
+
disabled?: boolean
|
|
117
|
+
isSending?: boolean
|
|
118
|
+
/** Ocupa o lugar do enviar enquanto não há nada para enviar — onde o WhatsApp põe o microfone. */
|
|
119
|
+
idleAction?: ReactNode
|
|
120
|
+
/** Prévia dos anexos já escolhidos, desenhada pelo host acima da barra. */
|
|
121
|
+
attachmentsPreview?: ReactNode
|
|
122
|
+
acceptedFileTypes?: string
|
|
123
|
+
className?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMessageComposerProps>(function RichMessageComposer({
|
|
127
|
+
value,
|
|
128
|
+
onChange,
|
|
129
|
+
onSend,
|
|
130
|
+
onAttachFiles,
|
|
131
|
+
quickReplies,
|
|
132
|
+
variables,
|
|
133
|
+
toolbar,
|
|
134
|
+
tooltips,
|
|
135
|
+
placeholder,
|
|
136
|
+
disabled = false,
|
|
137
|
+
isSending = false,
|
|
138
|
+
idleAction,
|
|
139
|
+
attachmentsPreview,
|
|
140
|
+
acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
|
|
141
|
+
className,
|
|
142
|
+
}, ref) {
|
|
143
|
+
const editorRef = useRef<HTMLDivElement>(null)
|
|
144
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
145
|
+
const variablesRef = useRef<HTMLDivElement>(null)
|
|
146
|
+
const [isVariablesOpen, setIsVariablesOpen] = useState(false)
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!isVariablesOpen) return
|
|
150
|
+
const closeOnOutsideClick = (event: MouseEvent) => {
|
|
151
|
+
if (!variablesRef.current?.contains(event.target as Node)) setIsVariablesOpen(false)
|
|
152
|
+
}
|
|
153
|
+
document.addEventListener('mousedown', closeOnOutsideClick)
|
|
154
|
+
return () => document.removeEventListener('mousedown', closeOnOutsideClick)
|
|
155
|
+
}, [isVariablesOpen])
|
|
156
|
+
|
|
157
|
+
const tooltipOf = (action: keyof RichComposerTooltips): string =>
|
|
158
|
+
tooltips?.[action] ?? DEFAULT_RICH_COMPOSER_TOOLTIPS[action]
|
|
159
|
+
const shows = (action: RichComposerAction): boolean => toolbar?.[action] !== false
|
|
160
|
+
|
|
161
|
+
/** Escreve no campo sem passar pelo React: `contentEditable` controlado perde o cursor a cada tecla. */
|
|
162
|
+
const replaceContent = useCallback((text: string) => {
|
|
163
|
+
const editor = editorRef.current
|
|
164
|
+
if (!editor) return
|
|
165
|
+
editor.innerHTML = waToHTML(text)
|
|
166
|
+
onChange(htmlToWA(editor.innerHTML))
|
|
167
|
+
editor.focus()
|
|
168
|
+
}, [onChange])
|
|
169
|
+
|
|
170
|
+
useImperativeHandle(ref, () => ({
|
|
171
|
+
setContent: replaceContent,
|
|
172
|
+
clear: () => {
|
|
173
|
+
const editor = editorRef.current
|
|
174
|
+
if (!editor) return
|
|
175
|
+
editor.innerHTML = ''
|
|
176
|
+
onChange('')
|
|
177
|
+
},
|
|
178
|
+
focus: () => editorRef.current?.focus(),
|
|
179
|
+
}), [onChange, replaceContent])
|
|
180
|
+
|
|
181
|
+
/** Range vivo dentro do campo, ou `undefined` se o cursor está em outro lugar da página. */
|
|
182
|
+
const currentRange = useCallback((): Range | undefined => {
|
|
183
|
+
const editor = editorRef.current
|
|
184
|
+
if (!editor) return undefined
|
|
185
|
+
editor.focus()
|
|
186
|
+
const selection = window.getSelection()
|
|
187
|
+
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return undefined
|
|
188
|
+
return selection.getRangeAt(0)
|
|
189
|
+
}, [])
|
|
190
|
+
|
|
191
|
+
const commitRange = useCallback((range: Range, node: Node) => {
|
|
192
|
+
const selection = window.getSelection()
|
|
193
|
+
range.setStartAfter(node)
|
|
194
|
+
range.collapse(true)
|
|
195
|
+
selection?.removeAllRanges()
|
|
196
|
+
selection?.addRange(range)
|
|
197
|
+
onChange(htmlToWA(editorRef.current?.innerHTML ?? ''))
|
|
198
|
+
}, [onChange])
|
|
199
|
+
|
|
200
|
+
const insertAtCursor = useCallback((fragment: string) => {
|
|
201
|
+
const range = currentRange()
|
|
202
|
+
if (!range) return
|
|
203
|
+
range.deleteContents()
|
|
204
|
+
const textNode = document.createTextNode(fragment)
|
|
205
|
+
range.insertNode(textNode)
|
|
206
|
+
commitRange(range, textNode)
|
|
207
|
+
}, [commitRange, currentRange])
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Envolve a seleção no elemento da formatação. Sem seleção não faz nada: abrir a marcação e
|
|
211
|
+
* esperar que o operador digite dentro dela é o caminho que sai com marcador sobrando quando ele
|
|
212
|
+
* clica noutro lugar antes.
|
|
213
|
+
*/
|
|
214
|
+
const wrapSelection = useCallback((action: keyof typeof FORMATTING_ELEMENT) => {
|
|
215
|
+
const range = currentRange()
|
|
216
|
+
const selectedText = range?.toString()
|
|
217
|
+
if (!range || !selectedText) return
|
|
218
|
+
range.deleteContents()
|
|
219
|
+
const wrapper = document.createElement(FORMATTING_ELEMENT[action])
|
|
220
|
+
if (action === 'monospace') wrapper.className = MONOSPACE_CLASS
|
|
221
|
+
wrapper.textContent = selectedText
|
|
222
|
+
range.insertNode(wrapper)
|
|
223
|
+
commitRange(range, wrapper)
|
|
224
|
+
}, [commitRange, currentRange])
|
|
225
|
+
|
|
226
|
+
const handleInput = useCallback((event: FormEvent<HTMLDivElement>) => {
|
|
227
|
+
onChange(htmlToWA(event.currentTarget.innerHTML))
|
|
228
|
+
}, [onChange])
|
|
229
|
+
|
|
230
|
+
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
|
231
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
232
|
+
event.preventDefault()
|
|
233
|
+
if (!disabled) onSend()
|
|
234
|
+
}
|
|
235
|
+
}, [disabled, onSend])
|
|
236
|
+
|
|
237
|
+
const handleFileChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
238
|
+
if (event.target.files?.length) onAttachFiles?.(event.target.files)
|
|
239
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
240
|
+
}, [onAttachFiles])
|
|
241
|
+
|
|
242
|
+
const canSend = value.trim().length > 0
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div className={cn('flex flex-col', className)}>
|
|
246
|
+
{attachmentsPreview}
|
|
247
|
+
|
|
248
|
+
{quickReplies?.length ? (
|
|
249
|
+
/* Uma linha só, rolando na horizontal. Deixar quebrar em várias linhas faz a barra crescer
|
|
250
|
+
conforme o número de respostas rápidas e empurrar a conversa para cima — o composer
|
|
251
|
+
precisa ter altura previsível, independente de quantos atalhos o host configurou. */
|
|
252
|
+
<div className="mb-2 flex flex-nowrap gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
253
|
+
{quickReplies.map((quickReply) => (
|
|
254
|
+
<button
|
|
255
|
+
key={quickReply.id}
|
|
256
|
+
type="button"
|
|
257
|
+
title={quickReply.tooltip}
|
|
258
|
+
onClick={() => replaceContent(quickReply.text)}
|
|
259
|
+
className="flex-shrink-0 whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 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"
|
|
260
|
+
>
|
|
261
|
+
{quickReply.label}
|
|
262
|
+
</button>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
) : null}
|
|
266
|
+
|
|
267
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
268
|
+
{/* Só no desktop: no celular não há como selecionar texto e tocar no botão sem perder a
|
|
269
|
+
seleção, e a formatação sai digitada à mão de qualquer jeito. */}
|
|
270
|
+
<div className="mb-0.5 hidden items-center gap-0.5 sm:flex">
|
|
271
|
+
{([
|
|
272
|
+
[RICH_COMPOSER_ACTION.BOLD, Bold],
|
|
273
|
+
[RICH_COMPOSER_ACTION.ITALIC, Italic],
|
|
274
|
+
[RICH_COMPOSER_ACTION.STRIKETHROUGH, Strikethrough],
|
|
275
|
+
[RICH_COMPOSER_ACTION.MONOSPACE, Code],
|
|
276
|
+
] as const)
|
|
277
|
+
.filter(([action]) => shows(action))
|
|
278
|
+
.map(([action, Icon]) => (
|
|
279
|
+
<button
|
|
280
|
+
key={action}
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={() => wrapSelection(action)}
|
|
283
|
+
title={tooltipOf(action)}
|
|
284
|
+
aria-label={tooltipOf(action)}
|
|
285
|
+
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
|
|
286
|
+
>
|
|
287
|
+
<Icon size={15} />
|
|
288
|
+
</button>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{shows(RICH_COMPOSER_ACTION.EMOJI) ? (
|
|
293
|
+
<SimpleEmojiPicker onSelect={insertAtCursor} label={tooltipOf('emoji')} />
|
|
294
|
+
) : null}
|
|
295
|
+
|
|
296
|
+
{variables?.length && shows(RICH_COMPOSER_ACTION.VARIABLES) ? (
|
|
297
|
+
<div ref={variablesRef} className="relative">
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
onClick={() => setIsVariablesOpen((open) => !open)}
|
|
301
|
+
title={tooltipOf('variables')}
|
|
302
|
+
aria-label={tooltipOf('variables')}
|
|
303
|
+
aria-expanded={isVariablesOpen}
|
|
304
|
+
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
|
|
305
|
+
>
|
|
306
|
+
<Braces size={18} />
|
|
307
|
+
</button>
|
|
308
|
+
{isVariablesOpen ? (
|
|
309
|
+
<div className="absolute bottom-full left-0 z-20 mb-2 max-h-56 w-64 overflow-y-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
|
310
|
+
{variables.map((variable) => (
|
|
311
|
+
<button
|
|
312
|
+
key={variable.id}
|
|
313
|
+
type="button"
|
|
314
|
+
title={variable.tooltip ?? variable.value}
|
|
315
|
+
onClick={() => { insertAtCursor(variable.value); setIsVariablesOpen(false) }}
|
|
316
|
+
className="flex w-full flex-col items-start px-3 py-1.5 text-left hover:bg-teal-50 dark:hover:bg-teal-950/40"
|
|
317
|
+
>
|
|
318
|
+
<span className="text-sm text-gray-800 dark:text-gray-100">{variable.label}</span>
|
|
319
|
+
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">{variable.value}</span>
|
|
320
|
+
</button>
|
|
321
|
+
))}
|
|
322
|
+
</div>
|
|
323
|
+
) : null}
|
|
324
|
+
</div>
|
|
325
|
+
) : null}
|
|
326
|
+
|
|
327
|
+
{onAttachFiles && shows(RICH_COMPOSER_ACTION.ATTACH) ? (
|
|
328
|
+
<>
|
|
329
|
+
<input
|
|
330
|
+
ref={fileInputRef}
|
|
331
|
+
type="file"
|
|
332
|
+
multiple
|
|
333
|
+
accept={acceptedFileTypes}
|
|
334
|
+
onChange={handleFileChange}
|
|
335
|
+
className="hidden"
|
|
336
|
+
/>
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
onClick={() => fileInputRef.current?.click()}
|
|
340
|
+
title={tooltipOf('attach')}
|
|
341
|
+
aria-label={tooltipOf('attach')}
|
|
342
|
+
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
|
|
343
|
+
>
|
|
344
|
+
<Paperclip size={18} />
|
|
345
|
+
</button>
|
|
346
|
+
</>
|
|
347
|
+
) : null}
|
|
348
|
+
|
|
349
|
+
<div
|
|
350
|
+
ref={editorRef}
|
|
351
|
+
contentEditable={!disabled}
|
|
352
|
+
suppressContentEditableWarning
|
|
353
|
+
role="textbox"
|
|
354
|
+
aria-multiline="true"
|
|
355
|
+
aria-label={placeholder}
|
|
356
|
+
data-placeholder={placeholder}
|
|
357
|
+
onInput={handleInput}
|
|
358
|
+
onKeyDown={handleKeyDown}
|
|
359
|
+
style={{ minHeight: '36px', maxHeight: '120px' }}
|
|
360
|
+
className="flex-1 min-w-[180px] overflow-y-auto rounded-2xl border border-gray-200 bg-gray-50 px-4 py-2 text-sm text-gray-800 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-teal-300 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 dark:empty:before:text-gray-500 before:pointer-events-none [&_strong]:font-semibold [&_em]:italic [&_del]:line-through [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-sm dark:[&_code]:bg-white/10"
|
|
361
|
+
/>
|
|
362
|
+
|
|
363
|
+
{!canSend && idleAction ? (
|
|
364
|
+
<div className="flex-shrink-0">{idleAction}</div>
|
|
365
|
+
) : (
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
onClick={onSend}
|
|
369
|
+
disabled={disabled || isSending || !canSend}
|
|
370
|
+
title={tooltipOf('send')}
|
|
371
|
+
aria-label={tooltipOf('send')}
|
|
372
|
+
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-teal-600 text-white transition-colors hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
373
|
+
>
|
|
374
|
+
{isSending ? <span className="text-xs">⏳</span> : <SendHorizonal size={16} />}
|
|
375
|
+
</button>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
)
|
|
380
|
+
})
|
|
@@ -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("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,13 +1,61 @@
|
|
|
1
|
-
|
|
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 type { CSSProperties, ReactNode } from 'react'
|
|
12
|
+
|
|
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
|
+
}
|
|
2
42
|
|
|
3
43
|
export interface ConversationWallpaperProps {
|
|
4
44
|
children?: ReactNode
|
|
5
45
|
className?: string
|
|
46
|
+
/** Ajusta ou substitui o fundo padrão — para produto com identidade visual própria. */
|
|
47
|
+
style?: CSSProperties
|
|
6
48
|
}
|
|
7
49
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
)
|
|
13
61
|
}
|
|
@@ -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=
|
|
120
|
+
<button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title={editorLabels.boldHint} aria-label={editorLabels.bold}>
|
|
97
121
|
<Bold size={15} />
|
|
98
122
|
</button>
|
|
99
|
-
<button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title=
|
|
123
|
+
<button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title={editorLabels.italicHint} aria-label={editorLabels.italic}>
|
|
100
124
|
<Italic size={15} />
|
|
101
125
|
</button>
|
|
102
|
-
<button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title=
|
|
126
|
+
<button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title={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={
|
|
138
|
+
title={editorLabels.insertPlaceholder(token)}
|
|
115
139
|
>
|
|
116
140
|
{token}
|
|
117
141
|
</button>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aviso de janela de 24h expirada, no lugar do composer.
|
|
3
|
+
*
|
|
4
|
+
* Trocar o composer pelo aviso, em vez de deixar o campo lá e falhar no envio, é o ponto: fora da
|
|
5
|
+
* janela o WhatsApp recusa texto livre, e um campo habilitado promete algo que a plataforma não
|
|
6
|
+
* cumpre. O caminho que resta — template — fica visível na mesma altura da tela.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CONVERSATION_WINDOW, type ConversationWindow } from './conversationWindow'
|
|
10
|
+
import { cn } from './lib/cn'
|
|
11
|
+
|
|
12
|
+
export interface WindowExpiredNoticeLabels {
|
|
13
|
+
title: string
|
|
14
|
+
description: string
|
|
15
|
+
sendTemplate: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_WINDOW_EXPIRED_LABELS: WindowExpiredNoticeLabels = {
|
|
19
|
+
title: '⏰ Janela de 24h expirada — o WhatsApp não aceita mensagens livres.',
|
|
20
|
+
description: 'Envie uma mensagem de template para reabrir a conversa com o cliente.',
|
|
21
|
+
sendTemplate: '📨 Enviar Template (HSM)',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WindowExpiredNoticeProps {
|
|
25
|
+
onSendTemplate?: () => void
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
labels?: Partial<WindowExpiredNoticeLabels>
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function WindowExpiredNotice({
|
|
32
|
+
onSendTemplate,
|
|
33
|
+
disabled = false,
|
|
34
|
+
labels: labelsOverride,
|
|
35
|
+
className,
|
|
36
|
+
}: WindowExpiredNoticeProps) {
|
|
37
|
+
const labels = { ...DEFAULT_WINDOW_EXPIRED_LABELS, ...labelsOverride }
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div role="status" className={cn('border-t bg-yellow-50 px-4 py-3 text-sm dark:bg-yellow-950', className)}>
|
|
41
|
+
<p className="font-medium text-yellow-900 dark:text-yellow-200">{labels.title}</p>
|
|
42
|
+
<p className="text-yellow-800 dark:text-yellow-300">{labels.description}</p>
|
|
43
|
+
{onSendTemplate ? (
|
|
44
|
+
<button type="button" onClick={onSendTemplate} disabled={disabled} className="cv-header-action mt-2">
|
|
45
|
+
{labels.sendTemplate}
|
|
46
|
+
</button>
|
|
47
|
+
) : null}
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Só a faixa `expired` bloqueia: 21-24h ainda aceita texto livre e merece alerta, não impedimento.
|
|
54
|
+
*/
|
|
55
|
+
export function isWindowBlocking(window: ConversationWindow): boolean {
|
|
56
|
+
return window === CONVERSATION_WINDOW.EXPIRED
|
|
57
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveRecordingFormat } from './AudioRecorderButton'
|
|
4
|
+
import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
5
|
+
|
|
6
|
+
const originalMediaRecorder = (globalThis as Record<string, unknown>).MediaRecorder
|
|
7
|
+
|
|
8
|
+
function stubMediaRecorder(supported: readonly string[] | undefined): void {
|
|
9
|
+
const stub = supported ? { isTypeSupported: (mimeType: string) => supported.includes(mimeType) } : {}
|
|
10
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = stub
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = originalMediaRecorder
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('resolveRecordingFormat', () => {
|
|
18
|
+
it('prefere ogg/opus, que o WhatsApp aceita, sobre webm', () => {
|
|
19
|
+
stubMediaRecorder(['audio/ogg;codecs=opus', 'audio/webm'])
|
|
20
|
+
|
|
21
|
+
expect(resolveRecordingFormat()?.uploadMimeType).toBe('audio/ogg')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('cai para mp4 no navegador que não grava ogg', () => {
|
|
25
|
+
stubMediaRecorder(['audio/mp4', 'audio/webm'])
|
|
26
|
+
|
|
27
|
+
const format = resolveRecordingFormat()
|
|
28
|
+
expect(format?.uploadMimeType).toBe('audio/mp4')
|
|
29
|
+
expect(format?.extension).toBe('m4a')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('devolve indefinido quando o navegador não grava nenhum formato', () => {
|
|
33
|
+
stubMediaRecorder([])
|
|
34
|
+
|
|
35
|
+
expect(resolveRecordingFormat()).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('devolve indefinido sem MediaRecorder no ambiente', () => {
|
|
39
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = undefined
|
|
40
|
+
|
|
41
|
+
expect(resolveRecordingFormat()).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('DEFAULT_ACCEPTED_FILE_TYPES', () => {
|
|
46
|
+
it('cobre Word, Excel e PowerPoint nos dois formatos, legado e OpenXML', () => {
|
|
47
|
+
const accepted = DEFAULT_ACCEPTED_FILE_TYPES.split(',')
|
|
48
|
+
|
|
49
|
+
expect(accepted).toContain('application/msword')
|
|
50
|
+
expect(accepted).toContain('application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
|
51
|
+
expect(accepted).toContain('application/vnd.ms-excel')
|
|
52
|
+
expect(accepted).toContain('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
53
|
+
expect(accepted).toContain('application/vnd.ms-powerpoint')
|
|
54
|
+
expect(accepted).toContain('application/vnd.openxmlformats-officedocument.presentationml.presentation')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('cobre texto, PDF, áudio e vídeo, e não oferece formato que o WhatsApp recusa', () => {
|
|
58
|
+
const accepted = DEFAULT_ACCEPTED_FILE_TYPES.split(',')
|
|
59
|
+
|
|
60
|
+
expect(accepted).toContain('application/pdf')
|
|
61
|
+
expect(accepted).toContain('text/plain')
|
|
62
|
+
expect(accepted).toContain('audio/ogg')
|
|
63
|
+
expect(accepted).toContain('video/mp4')
|
|
64
|
+
expect(DEFAULT_ACCEPTED_FILE_TYPES).not.toContain('.zip')
|
|
65
|
+
expect(DEFAULT_ACCEPTED_FILE_TYPES).not.toContain('.rtf')
|
|
66
|
+
})
|
|
67
|
+
})
|