@adatechnology/conversations-ui 0.1.0-rc.1 → 0.1.0-rc.11
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-3RKFU46R.js +1778 -0
- package/dist/flows/index.js +3 -3
- package/dist/index.d.ts +644 -136
- package/dist/index.js +1068 -1024
- package/dist/preview/index.d.ts +341 -0
- package/dist/preview/index.js +1296 -0
- package/dist/styles.css +198 -0
- package/dist/types-B5C1DLu1.d.ts +365 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -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 +14 -11
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +146 -19
- 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/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/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowMapCanvas.tsx +2 -2
- 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 +114 -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/AudioRecorderButton.tsx +155 -0
- package/src/preview/ConversationPreview.tsx +322 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/audioRecorderFormat.test.ts +67 -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/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +126 -0
- package/src/preview/index.ts +54 -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
package/src/MessageComposer.tsx
CHANGED
|
@@ -1,8 +1,56 @@
|
|
|
1
|
-
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent } from 'react'
|
|
1
|
+
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
|
|
2
2
|
import type { ConversationsFeatures } from './types'
|
|
3
|
+
import { cn } from './lib/cn'
|
|
3
4
|
import { EmojiPicker } from './EmojiPicker'
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Mensagem pronta que o atendente cola no campo com um clique.
|
|
8
|
+
*
|
|
9
|
+
* `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
|
|
10
|
+
* nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
|
|
11
|
+
* escolher texto por produto, pluralizar, formatar moeda.
|
|
12
|
+
*/
|
|
13
|
+
export interface QuickReply {
|
|
14
|
+
key: string
|
|
15
|
+
/** O que aparece no chip, emoji incluso: `👋 Saudação`. */
|
|
16
|
+
label: string
|
|
17
|
+
text: string | ((variables: Readonly<Record<string, string>>) => string)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
|
|
22
|
+
* `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
|
|
23
|
+
*/
|
|
24
|
+
export function applyQuickReplyVariables(
|
|
25
|
+
template: string,
|
|
26
|
+
variables: Readonly<Record<string, string>> = {},
|
|
27
|
+
): string {
|
|
28
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveQuickReply(
|
|
32
|
+
quickReply: QuickReply,
|
|
33
|
+
variables: Readonly<Record<string, string>> = {},
|
|
34
|
+
): string {
|
|
35
|
+
return typeof quickReply.text === 'function'
|
|
36
|
+
? quickReply.text(variables)
|
|
37
|
+
: applyQuickReplyVariables(quickReply.text, variables)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MessageComposerLabels {
|
|
41
|
+
emoji: string
|
|
42
|
+
attach: string
|
|
43
|
+
send: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
|
|
47
|
+
emoji: 'Emoji',
|
|
48
|
+
attach: 'Anexar',
|
|
49
|
+
send: 'Enviar',
|
|
50
|
+
}
|
|
51
|
+
|
|
5
52
|
export interface MessageComposerProps {
|
|
53
|
+
labels?: Partial<MessageComposerLabels>
|
|
6
54
|
onSend: (text: string) => void
|
|
7
55
|
onAttach?: (file: File) => void
|
|
8
56
|
value?: string
|
|
@@ -12,10 +60,42 @@ export interface MessageComposerProps {
|
|
|
12
60
|
maxLength?: number
|
|
13
61
|
disabled?: boolean
|
|
14
62
|
acceptedFileTypes?: string
|
|
63
|
+
/**
|
|
64
|
+
* Ocupa o lugar do botão de enviar enquanto não há nada para enviar — é onde o WhatsApp põe o
|
|
65
|
+
* microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
|
|
66
|
+
*/
|
|
67
|
+
idleAction?: ReactNode
|
|
68
|
+
/** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
|
|
69
|
+
quickReplies?: readonly QuickReply[]
|
|
70
|
+
/** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
|
|
71
|
+
quickReplyVariables?: Readonly<Record<string, string>>
|
|
72
|
+
className?: string
|
|
73
|
+
classNames?: Partial<MessageComposerClassNames>
|
|
15
74
|
}
|
|
16
75
|
|
|
17
|
-
|
|
18
|
-
|
|
76
|
+
export interface MessageComposerClassNames {
|
|
77
|
+
root: string
|
|
78
|
+
quickReplies: string
|
|
79
|
+
quickReply: string
|
|
80
|
+
field: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Exatamente o que a Meta aceita em mensagem de mídia — imagem, sticker, áudio, vídeo e a lista
|
|
85
|
+
* fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
|
|
86
|
+
* empurra a falha para depois do envio, quando já não dá para explicar ao operador o que houve.
|
|
87
|
+
* Produto com regra própria passa `acceptedFileTypes`.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_ACCEPTED_FILE_TYPES = [
|
|
90
|
+
'image/jpeg,image/png,image/webp',
|
|
91
|
+
'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
|
|
92
|
+
'video/mp4,video/3gpp',
|
|
93
|
+
'application/pdf,text/plain,text/csv',
|
|
94
|
+
'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
|
|
95
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
96
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
97
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
98
|
+
].join(',')
|
|
19
99
|
|
|
20
100
|
interface FilePreview {
|
|
21
101
|
file: File
|
|
@@ -27,12 +107,21 @@ export const MessageComposer = ({
|
|
|
27
107
|
onAttach,
|
|
28
108
|
value: externalValue,
|
|
29
109
|
onChange: externalOnChange,
|
|
110
|
+
quickReplies,
|
|
111
|
+
quickReplyVariables,
|
|
30
112
|
features,
|
|
31
113
|
placeholder = 'Digite uma mensagem...',
|
|
32
114
|
maxLength,
|
|
33
115
|
disabled = false,
|
|
34
116
|
acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
|
|
117
|
+
idleAction,
|
|
118
|
+
className,
|
|
119
|
+
classNames,
|
|
120
|
+
labels,
|
|
35
121
|
}: MessageComposerProps) => {
|
|
122
|
+
const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
|
|
123
|
+
const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
|
|
124
|
+
const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
|
|
36
125
|
const [internalText, setInternalText] = useState('')
|
|
37
126
|
const [showEmoji, setShowEmoji] = useState(false)
|
|
38
127
|
const [attachments, setAttachments] = useState<FilePreview[]>([])
|
|
@@ -134,7 +223,41 @@ export const MessageComposer = ({
|
|
|
134
223
|
const remaining = maxLength ? maxLength - text.length : null
|
|
135
224
|
|
|
136
225
|
return (
|
|
137
|
-
|
|
226
|
+
/* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
|
|
227
|
+
ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
|
|
228
|
+
descobriam o fundo branco da página, que lia como defeito. */
|
|
229
|
+
<div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
|
|
230
|
+
{/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
|
|
231
|
+
o campo para fora da tela, e o campo é a razão de a barra existir. */}
|
|
232
|
+
{quickReplies && quickReplies.length > 0 && (
|
|
233
|
+
<div
|
|
234
|
+
className={cn(
|
|
235
|
+
'mb-2 flex flex-nowrap gap-1 overflow-x-auto px-1 sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
236
|
+
classNames?.quickReplies,
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
{quickReplies.map((quickReply) => (
|
|
240
|
+
<button
|
|
241
|
+
key={quickReply.key}
|
|
242
|
+
type="button"
|
|
243
|
+
// Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
|
|
244
|
+
// atende quase sempre ajusta uma palavra antes de mandar. Enviar direto no clique
|
|
245
|
+
// transformaria um toque errado em mensagem entregue ao cliente.
|
|
246
|
+
onClick={() => {
|
|
247
|
+
setText(resolveQuickReply(quickReply, quickReplyVariables))
|
|
248
|
+
textareaRef.current?.focus()
|
|
249
|
+
}}
|
|
250
|
+
className={cn(
|
|
251
|
+
'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',
|
|
252
|
+
classNames?.quickReply,
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
{quickReply.label}
|
|
256
|
+
</button>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
138
261
|
{attachments.length > 0 && (
|
|
139
262
|
<div className="flex gap-2 px-1 pb-2 overflow-x-auto">
|
|
140
263
|
{attachments.map((a, i) => (
|
|
@@ -152,10 +275,10 @@ export const MessageComposer = ({
|
|
|
152
275
|
</div>
|
|
153
276
|
)}
|
|
154
277
|
|
|
155
|
-
<div className=
|
|
278
|
+
<div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
|
|
156
279
|
{showEmojiButton && (
|
|
157
280
|
<div className="relative flex-shrink-0">
|
|
158
|
-
<button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label=
|
|
281
|
+
<button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
|
|
159
282
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
|
|
160
283
|
</button>
|
|
161
284
|
{showEmoji && (
|
|
@@ -181,24 +304,28 @@ export const MessageComposer = ({
|
|
|
181
304
|
{showAttachButton && (
|
|
182
305
|
<>
|
|
183
306
|
<input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
|
|
184
|
-
<button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label=
|
|
307
|
+
<button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
|
|
185
308
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
186
309
|
</button>
|
|
187
310
|
</>
|
|
188
311
|
)}
|
|
189
312
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
313
|
+
{!canSend && idleAction ? (
|
|
314
|
+
<div className="flex-shrink-0">{idleAction}</div>
|
|
315
|
+
) : (
|
|
316
|
+
<button
|
|
317
|
+
onClick={sendMessage}
|
|
318
|
+
disabled={!canSend || disabled}
|
|
319
|
+
className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
|
|
320
|
+
canSend && !disabled
|
|
321
|
+
? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
|
|
322
|
+
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
323
|
+
}`}
|
|
324
|
+
aria-label={sendLabel}
|
|
325
|
+
>
|
|
326
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
|
327
|
+
</button>
|
|
328
|
+
)}
|
|
202
329
|
</div>
|
|
203
330
|
|
|
204
331
|
{remaining !== null && (
|
|
@@ -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,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { CONVERSATION_CHANNEL, CHANNEL_FILTER_ALL, channelFiltersFor } from './conversationChannel'
|
|
3
|
+
|
|
4
|
+
describe('channelFiltersFor', () => {
|
|
5
|
+
// O ponto do helper: a barra oferece só o que existe. Oferecer Instagram numa conta que só tem
|
|
6
|
+
// WhatsApp promete um recorte que nunca traz resultado.
|
|
7
|
+
it('lista apenas os canais presentes, com Todos na frente', () => {
|
|
8
|
+
const options = channelFiltersFor([
|
|
9
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
10
|
+
{ channel: CONVERSATION_CHANNEL.INSTAGRAM },
|
|
11
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
expect(options.map((option) => option.value)).toEqual([
|
|
15
|
+
CHANNEL_FILTER_ALL,
|
|
16
|
+
CONVERSATION_CHANNEL.WHATSAPP,
|
|
17
|
+
CONVERSATION_CHANNEL.INSTAGRAM,
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('não oferece filtro quando há um canal só', () => {
|
|
22
|
+
expect(channelFiltersFor([{ channel: CONVERSATION_CHANNEL.WHATSAPP }, {}])).toEqual([])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('não oferece filtro para lista vazia', () => {
|
|
26
|
+
expect(channelFiltersFor([])).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Conversa sem canal é WhatsApp por compatibilidade — não pode virar uma quinta opção fantasma.
|
|
30
|
+
it('trata ausência de canal como whatsapp', () => {
|
|
31
|
+
const options = channelFiltersFor([{}, { channel: CONVERSATION_CHANNEL.WEBCHAT }])
|
|
32
|
+
|
|
33
|
+
expect(options.map((option) => option.value)).toEqual([
|
|
34
|
+
CHANNEL_FILTER_ALL,
|
|
35
|
+
CONVERSATION_CHANNEL.WHATSAPP,
|
|
36
|
+
CONVERSATION_CHANNEL.WEBCHAT,
|
|
37
|
+
])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Ordem do catálogo: se dependesse da ordem de chegada, a barra se reorganizaria a cada refetch.
|
|
41
|
+
it('mantém ordem estável independente da ordem da lista', () => {
|
|
42
|
+
const first = channelFiltersFor([
|
|
43
|
+
{ channel: CONVERSATION_CHANNEL.WEBCHAT },
|
|
44
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
45
|
+
])
|
|
46
|
+
const second = channelFiltersFor([
|
|
47
|
+
{ channel: CONVERSATION_CHANNEL.WHATSAPP },
|
|
48
|
+
{ channel: CONVERSATION_CHANNEL.WEBCHAT },
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
expect(first).toEqual(second)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal de origem da conversa e o que cada um permite.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque as regras que a inbox precisa respeitar não são do WhatsApp, são **de cada canal**:
|
|
5
|
+
* a janela de sessão, o jeito de reabrir a conversa e o formato do identificador do contato mudam
|
|
6
|
+
* entre WhatsApp, Messenger, Instagram e chat de site. Tratar a regra do WhatsApp como universal
|
|
7
|
+
* faria a UI bloquear o composer num chat de site, onde janela nenhuma existe.
|
|
8
|
+
*
|
|
9
|
+
* `whatsapp` é o padrão em todo lugar: instalações que ainda não informam canal continuam
|
|
10
|
+
* funcionando exatamente como antes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatPhone, phoneCountryFlag } from './lib/phone'
|
|
14
|
+
|
|
15
|
+
export const CONVERSATION_CHANNEL = {
|
|
16
|
+
WHATSAPP: 'whatsapp',
|
|
17
|
+
MESSENGER: 'messenger',
|
|
18
|
+
INSTAGRAM: 'instagram',
|
|
19
|
+
WEBCHAT: 'webchat',
|
|
20
|
+
} as const
|
|
21
|
+
export type ConversationChannel = (typeof CONVERSATION_CHANNEL)[keyof typeof CONVERSATION_CHANNEL]
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_CONVERSATION_CHANNEL: ConversationChannel = CONVERSATION_CHANNEL.WHATSAPP
|
|
24
|
+
|
|
25
|
+
/** Como o canal reabre uma conversa fora da janela de sessão. */
|
|
26
|
+
export const REOPEN_MECHANISM = {
|
|
27
|
+
TEMPLATE: 'template',
|
|
28
|
+
TAG: 'tag',
|
|
29
|
+
NONE: 'none',
|
|
30
|
+
} as const
|
|
31
|
+
export type ReopenMechanism = (typeof REOPEN_MECHANISM)[keyof typeof REOPEN_MECHANISM]
|
|
32
|
+
|
|
33
|
+
/** Natureza do identificador do contato — decide como exibi-lo. */
|
|
34
|
+
export const HANDLE_KIND = {
|
|
35
|
+
PHONE: 'phone',
|
|
36
|
+
USERNAME: 'username',
|
|
37
|
+
SESSION: 'session',
|
|
38
|
+
} as const
|
|
39
|
+
export type HandleKind = (typeof HANDLE_KIND)[keyof typeof HANDLE_KIND]
|
|
40
|
+
|
|
41
|
+
export type ChannelCapabilities = {
|
|
42
|
+
readonly label: string
|
|
43
|
+
readonly icon: string
|
|
44
|
+
readonly hasSessionWindow: boolean
|
|
45
|
+
readonly windowHours: number
|
|
46
|
+
readonly reopenMechanism: ReopenMechanism
|
|
47
|
+
readonly handleKind: HandleKind
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelCapabilities>> = {
|
|
51
|
+
[CONVERSATION_CHANNEL.WHATSAPP]: {
|
|
52
|
+
label: 'WhatsApp',
|
|
53
|
+
icon: '💬',
|
|
54
|
+
hasSessionWindow: true,
|
|
55
|
+
windowHours: 24,
|
|
56
|
+
reopenMechanism: REOPEN_MECHANISM.TEMPLATE,
|
|
57
|
+
handleKind: HANDLE_KIND.PHONE,
|
|
58
|
+
},
|
|
59
|
+
[CONVERSATION_CHANNEL.MESSENGER]: {
|
|
60
|
+
// Messenger também tem 24h, mas reabre com message tag — não com template aprovado.
|
|
61
|
+
label: 'Messenger',
|
|
62
|
+
icon: '📨',
|
|
63
|
+
hasSessionWindow: true,
|
|
64
|
+
windowHours: 24,
|
|
65
|
+
reopenMechanism: REOPEN_MECHANISM.TAG,
|
|
66
|
+
handleKind: HANDLE_KIND.USERNAME,
|
|
67
|
+
},
|
|
68
|
+
[CONVERSATION_CHANNEL.INSTAGRAM]: {
|
|
69
|
+
label: 'Instagram',
|
|
70
|
+
icon: '📷',
|
|
71
|
+
hasSessionWindow: true,
|
|
72
|
+
windowHours: 24,
|
|
73
|
+
reopenMechanism: REOPEN_MECHANISM.TAG,
|
|
74
|
+
handleKind: HANDLE_KIND.USERNAME,
|
|
75
|
+
},
|
|
76
|
+
[CONVERSATION_CHANNEL.WEBCHAT]: {
|
|
77
|
+
// Chat próprio: sem intermediário, sem janela. Bloquear o composer aqui seria inventar limite.
|
|
78
|
+
label: 'Chat do site',
|
|
79
|
+
icon: '🌐',
|
|
80
|
+
hasSessionWindow: false,
|
|
81
|
+
windowHours: 0,
|
|
82
|
+
reopenMechanism: REOPEN_MECHANISM.NONE,
|
|
83
|
+
handleKind: HANDLE_KIND.SESSION,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function capabilitiesOf(channel: ConversationChannel | undefined): ChannelCapabilities {
|
|
88
|
+
return CHANNEL_CAPABILITIES[channel ?? DEFAULT_CONVERSATION_CHANNEL]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const CHANNEL_FILTER_ALL = 'all'
|
|
92
|
+
export type ChannelFilter = ConversationChannel | typeof CHANNEL_FILTER_ALL
|
|
93
|
+
|
|
94
|
+
export type ChannelFilterOption = {
|
|
95
|
+
readonly value: ChannelFilter
|
|
96
|
+
readonly label: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Opções derivadas do que existe na lista, não do catálogo inteiro: oferecer Instagram numa conta
|
|
101
|
+
* que só tem WhatsApp promete um recorte que nunca traz resultado.
|
|
102
|
+
*
|
|
103
|
+
* Devolve vazio com menos de dois canais — um filtro de opção única não filtra nada, e a barra só
|
|
104
|
+
* ocuparia espaço. O host usa isso para esconder a seção.
|
|
105
|
+
*/
|
|
106
|
+
export function channelFiltersFor(
|
|
107
|
+
conversations: readonly { readonly channel?: ConversationChannel | undefined }[],
|
|
108
|
+
): ChannelFilterOption[] {
|
|
109
|
+
const present = new Set<ConversationChannel>(
|
|
110
|
+
conversations.map((conversation) => conversation.channel ?? DEFAULT_CONVERSATION_CHANNEL),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if (present.size < 2) return []
|
|
114
|
+
|
|
115
|
+
// Ordem do catálogo, não de chegada: a barra não pode reordenar sozinha a cada refetch.
|
|
116
|
+
const ordered = (Object.keys(CHANNEL_CAPABILITIES) as ConversationChannel[]).filter((channel) => present.has(channel))
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
{ value: CHANNEL_FILTER_ALL, label: 'Todos' },
|
|
120
|
+
...ordered.map((channel) => ({ value: channel, label: CHANNEL_CAPABILITIES[channel].label })),
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type FormatContactHandleParams = {
|
|
125
|
+
readonly handle: string
|
|
126
|
+
readonly channel?: ConversationChannel | undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Exibição do identificador conforme a natureza dele. Formatar tudo como telefone — o que a UI
|
|
131
|
+
* fazia — transforma um `@perfil` do Instagram em dígitos sem sentido.
|
|
132
|
+
*/
|
|
133
|
+
export function formatContactHandle(params: FormatContactHandleParams): string {
|
|
134
|
+
const { handleKind } = capabilitiesOf(params.channel)
|
|
135
|
+
|
|
136
|
+
if (handleKind === HANDLE_KIND.PHONE) return formatPhone(params.handle)
|
|
137
|
+
if (handleKind === HANDLE_KIND.USERNAME) return params.handle.startsWith('@') ? params.handle : `@${params.handle}`
|
|
138
|
+
|
|
139
|
+
// Sessão de chat de site é um id opaco: mostrar o hash inteiro não ajuda ninguém.
|
|
140
|
+
return `Visitante ${params.handle.slice(-6)}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Bandeira só faz sentido quando o identificador é telefone. */
|
|
144
|
+
export function contactFlag(params: FormatContactHandleParams): string {
|
|
145
|
+
return capabilitiesOf(params.channel).handleKind === HANDLE_KIND.PHONE ? phoneCountryFlag(params.handle) : ''
|
|
146
|
+
}
|