@adatechnology/conversations-ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2166 -509
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +289 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +155 -0
  49. package/src/workspace/useComposerQueue.ts +220 -0
package/src/index.ts CHANGED
@@ -254,11 +254,38 @@ export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
254
254
  export type { ConversationHeaderUtility } from './ConversationHeader'
255
255
  export { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
256
256
  export type { QuickReply } from './MessageComposer'
257
+ // `QuickReply` já nomeia o chip; a mensagem pronta cadastrada sai como `SavedQuickReply` para não quebrar hosts.
258
+ export type {
259
+ QuickReply as SavedQuickReply,
260
+ QuickReplyInput,
261
+ QuickReplyAttachment,
262
+ QueuedAttachment,
263
+ StoredAttachmentSendResult,
264
+ ConversationVariable,
265
+ } from './quickReplies/quickReply.types'
266
+
267
+ // Só o que o host precisa para montar a fila e ler seus tetos. O resto (validação de arquivo,
268
+ // ordenação de envio, limite de concorrência de upload) é orquestração interna do pacote — expô-lo
269
+ // convida o host a reimplementar o pipeline por fora em vez de usar `ConversationPane` (L2).
270
+ export { QUICK_REPLY_ATTACHMENT_LIMIT } from './quickReplies/quickReply.types'
271
+ export {
272
+ DEFAULT_MAX_ATTACHMENT_SIZE_BYTES,
273
+ resolveMaxAttachmentSizeBytes,
274
+ queuedAttachmentsFromQuickReply,
275
+ } from './quickReplies/quickReplyAttachments'
276
+ export type { MaxAttachmentSizeBytes, OutgoingItems } from './quickReplies/quickReplyAttachments'
277
+ export type { AttachmentFileRejection, ValidateAttachmentFilesResult } from './quickReplies/quickReplyAttachmentUpload'
278
+ export { QuickRepliesWorkspace, QuickRepliesPicker } from './quickReplies'
279
+ export type { QuickRepliesWorkspaceProps, QuickRepliesPickerProps } from './quickReplies'
280
+ export { DEFAULT_QUICK_REPLIES_PICKER_LABELS, DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS } from './quickReplies/labels'
281
+ export type { QuickRepliesPickerLabels, QuickRepliesWorkspaceLabels } from './quickReplies/labels'
282
+ export { normalizeForSearch, filterQuickReplies, highlightMatch } from './quickReplies/quickReplySearch'
283
+ export type { FilterQuickRepliesParams, MatchSegment } from './quickReplies/quickReplySearch'
257
284
 
258
285
  // Tela de atendimento completa. Fica no export principal — e não num subpath — porque é a
259
286
  // composição padrão do pacote: quem instala conversas quer esta tela, e as peças continuam
260
287
  // exportadas ao lado para quem precisar montar outra.
261
- export { ConversationsWorkspace, ConversationPane, ConversationsInboxList } from './workspace'
288
+ export { ConversationsWorkspace, ConversationPane, ConversationsInboxList, QueuedAttachmentsList } from './workspace'
262
289
  export { useConversationsInbox, CONVERSATIONS_PER_PAGE, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './workspace'
263
290
  export type {
264
291
  ConversationsWorkspaceProps,
@@ -16,6 +16,7 @@ import type {
16
16
  ConversationsApi,
17
17
  ListConversationsParams,
18
18
  } from '../providers/types'
19
+ import type { QuickReply, QuickReplyInput } from '../quickReplies/quickReply.types'
19
20
  import { PREVIEW_DOCUMENTS } from './previewFixtures'
20
21
  import { previewFileBase64, previewFileUrl } from './previewMediaSource'
21
22
  import type { PreviewStore } from './previewStore'
@@ -36,6 +37,38 @@ const PREVIEW_TEMPLATES: readonly ConversationTemplate[] = [
36
37
  { name: 'promocao_taxa', language: 'pt_BR', status: 'PENDING', category: 'MARKETING' },
37
38
  ]
38
39
 
40
+ const PREVIEW_QUICK_REPLIES: QuickReply[] = [
41
+ { id: '1', title: 'Boas-vindas', shortcut: 'ola', body: 'Olá {{nome}}, bem-vindo!' },
42
+ {
43
+ id: '2',
44
+ title: 'Lista de documentos',
45
+ shortcut: 'documentos',
46
+ body: 'Segue a lista de documentos necessários para prosseguir com sua solicitação.',
47
+ attachments: [
48
+ { uploadId: 'upload-1', filename: 'Checklist de documentos.pdf', mimeType: 'application/pdf', sizeBytes: 245000 },
49
+ { uploadId: 'upload-2', filename: 'Tabela de taxas.png', mimeType: 'image/png', sizeBytes: 125000 },
50
+ ],
51
+ },
52
+ {
53
+ id: '3',
54
+ title: 'Prazo de análise',
55
+ shortcut: 'prazo',
56
+ body: 'Sua solicitação está em análise e o resultado sairá em até 48 horas.',
57
+ },
58
+ {
59
+ id: '4',
60
+ title: 'Agendar ligação',
61
+ shortcut: 'ligacao',
62
+ body: 'Olá {{nome}}, você gostaria de agendar uma ligação comigo? Que horas funcionam melhor para você?',
63
+ },
64
+ {
65
+ id: '5',
66
+ title: 'Encerramento',
67
+ shortcut: 'tchau',
68
+ body: 'Obrigado {{nome_completo}}, foi um prazer atender você. Qualquer dúvida, é só chamar!',
69
+ },
70
+ ]
71
+
39
72
  /**
40
73
  * O mock satisfaz `ConversationsApi`, mas com o retorno de `fetchConversations` ESTREITADO para a
41
74
  * forma paginada. Sem isto o contrato — que aceita array ou página — obrigaria todo consumidor do
@@ -267,5 +300,156 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
267
300
  listTemplates(): Promise<ConversationTemplate[]> {
268
301
  return withLatency(() => [...PREVIEW_TEMPLATES])
269
302
  },
303
+
304
+ listQuickReplies(searchParams?: { search?: string }): Promise<QuickReply[]> {
305
+ return withLatency(() => {
306
+ let results = [...PREVIEW_QUICK_REPLIES]
307
+ if (searchParams?.search) {
308
+ const term = searchParams.search.toLowerCase()
309
+ results = results.filter(
310
+ (qr) =>
311
+ qr.title.toLowerCase().includes(term) ||
312
+ qr.shortcut.toLowerCase().includes(term) ||
313
+ qr.body.toLowerCase().includes(term),
314
+ )
315
+ }
316
+ return results
317
+ })
318
+ },
319
+
320
+ createQuickReply(input: QuickReplyInput): Promise<QuickReply> {
321
+ return withLatency(() => {
322
+ // Check for duplicate shortcut
323
+ if (PREVIEW_QUICK_REPLIES.some((qr) => qr.shortcut === input.shortcut)) {
324
+ const error = new Error('Atalho já existe') as unknown as {
325
+ code?: string
326
+ details?: unknown[]
327
+ }
328
+ error.code = 'QUICK_REPLY_SHORTCUT_TAKEN'
329
+ throw error
330
+ }
331
+ const newQuickReply: QuickReply = {
332
+ id: String(Date.now()),
333
+ title: input.title,
334
+ shortcut: input.shortcut,
335
+ body: input.body,
336
+ }
337
+ PREVIEW_QUICK_REPLIES.push(newQuickReply)
338
+ return newQuickReply
339
+ })
340
+ },
341
+
342
+ updateQuickReply(id: string, input: QuickReplyInput): Promise<QuickReply> {
343
+ return withLatency(() => {
344
+ const index = PREVIEW_QUICK_REPLIES.findIndex((qr) => qr.id === id)
345
+ if (index === -1) {
346
+ throw new Error('Mensagem pronta não encontrada')
347
+ }
348
+ // Check for duplicate shortcut (excluding current item)
349
+ if (PREVIEW_QUICK_REPLIES.some((qr) => qr.id !== id && qr.shortcut === input.shortcut)) {
350
+ const error = new Error('Atalho já existe') as unknown as {
351
+ code?: string
352
+ details?: unknown[]
353
+ }
354
+ error.code = 'QUICK_REPLY_SHORTCUT_TAKEN'
355
+ throw error
356
+ }
357
+ const updated: QuickReply = {
358
+ id,
359
+ title: input.title,
360
+ shortcut: input.shortcut,
361
+ body: input.body,
362
+ }
363
+ PREVIEW_QUICK_REPLIES[index] = updated
364
+ return updated
365
+ })
366
+ },
367
+
368
+ deleteQuickReply(id: string): Promise<void> {
369
+ return withLatency(() => {
370
+ const index = PREVIEW_QUICK_REPLIES.findIndex((qr) => qr.id === id)
371
+ if (index !== -1) {
372
+ PREVIEW_QUICK_REPLIES.splice(index, 1)
373
+ }
374
+ })
375
+ },
376
+
377
+ uploadQuickReplyAttachment(
378
+ file: File,
379
+ options: { readonly onProgress: (progress: number) => void; readonly signal: AbortSignal },
380
+ ): Promise<{ uploadId: string; filename: string; mimeType: string; sizeBytes: number }> {
381
+ return new Promise((resolve, reject) => {
382
+ if (options.signal.aborted) {
383
+ reject(new DOMException('Aborted', 'AbortError'))
384
+ return
385
+ }
386
+
387
+ const abortHandler = () => {
388
+ reject(new DOMException('Aborted', 'AbortError'))
389
+ }
390
+ options.signal.addEventListener('abort', abortHandler)
391
+
392
+ // Simulação de progresso ao longo de ~1.5s (1500ms)
393
+ const steps = 6
394
+ const stepDuration = 1500 / steps
395
+ let step = 0
396
+
397
+ const progressInterval = setInterval(() => {
398
+ if (options.signal.aborted) {
399
+ clearInterval(progressInterval)
400
+ options.signal.removeEventListener('abort', abortHandler)
401
+ reject(new DOMException('Aborted', 'AbortError'))
402
+ return
403
+ }
404
+
405
+ step += 1
406
+ const progress = step / steps
407
+ if (progress <= 1) {
408
+ options.onProgress(Math.round(progress * 100))
409
+ }
410
+
411
+ if (step >= steps) {
412
+ clearInterval(progressInterval)
413
+ options.signal.removeEventListener('abort', abortHandler)
414
+ resolve({
415
+ uploadId: `upload-${Date.now()}-${Math.random().toString(36).substring(7)}`,
416
+ filename: file.name,
417
+ mimeType: file.type,
418
+ sizeBytes: file.size,
419
+ })
420
+ }
421
+ }, stepDuration)
422
+ })
423
+ },
424
+
425
+ sendStoredAttachments(params: {
426
+ conversationId: string
427
+ uploadIds: readonly string[]
428
+ idempotencyKey: string
429
+ }): Promise<{ results: readonly { uploadId: string; status: 'sent' | 'failed' | 'skipped' }[] }> {
430
+ return withLatency(() => {
431
+ const results: { uploadId: string; status: 'sent' | 'failed' | 'skipped' }[] = []
432
+
433
+ for (let index = 0; index < params.uploadIds.length; index += 1) {
434
+ const uploadId = params.uploadIds[index] as string
435
+ // Simular falha para uploads cuja nome no preview contém "falha", e pular os seguintes
436
+ const isFailed = uploadId.includes('falha')
437
+ if (isFailed) {
438
+ results.push({ uploadId, status: 'failed' })
439
+ continue
440
+ }
441
+
442
+ // Se o anterior falhou, pular este
443
+ if (index > 0 && results[index - 1]?.status === 'failed') {
444
+ results.push({ uploadId, status: 'skipped' })
445
+ continue
446
+ }
447
+
448
+ results.push({ uploadId, status: 'sent' })
449
+ }
450
+
451
+ return { results }
452
+ })
453
+ },
270
454
  }
271
455
  }
@@ -1,3 +1,9 @@
1
+ import type {
2
+ QuickReply,
3
+ QuickReplyAttachment,
4
+ QuickReplyInput,
5
+ StoredAttachmentSendResult,
6
+ } from '../quickReplies/quickReply.types'
1
7
  import type { MessagePayload, MessageTranscription } from '../types'
2
8
  import type { ConversationChannel } from '../conversationChannel'
3
9
 
@@ -173,6 +179,34 @@ export interface ConversationsApi {
173
179
  markAllRead?(): Promise<void>
174
180
  listTemplates?(): Promise<ConversationTemplate[]>
175
181
 
182
+ /**
183
+ * Mensagens prontas do produto. Ausente, nada de mensagens prontas aparece; sem `create`,
184
+ * `update` e `delete` a tela de cadastro fica só leitura — a presença da função é a capacidade.
185
+ */
186
+ listQuickReplies?(params?: { search?: string }): Promise<QuickReply[]>
187
+ createQuickReply?(input: QuickReplyInput): Promise<QuickReply>
188
+ updateQuickReply?(id: string, input: QuickReplyInput): Promise<QuickReply>
189
+ deleteQuickReply?(id: string): Promise<void>
190
+ /**
191
+ * Sobe um anexo de mensagem pronta. O pacote não sabe COMO o host sobe (URL assinada, multipart):
192
+ * só precisa de progresso e cancelamento. Ausente, a tela de cadastro não oferece anexos.
193
+ * A miniatura de imagem reusa `getDocumentUrl(uploadId, 'inline')` — não há porta própria.
194
+ */
195
+ uploadQuickReplyAttachment?(
196
+ file: File,
197
+ options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
198
+ ): Promise<QuickReplyAttachment>
199
+ /**
200
+ * Envia por referência anexos já guardados. A chave de idempotência protege o reenvio depois de
201
+ * queda de rede; o resultado vem por arquivo porque a falha é parcial. Ausente, mensagem pronta
202
+ * com anexo insere só o texto.
203
+ */
204
+ sendStoredAttachments?(params: {
205
+ conversationId: string
206
+ uploadIds: readonly string[]
207
+ idempotencyKey: string
208
+ }): Promise<{ results: readonly StoredAttachmentSendResult[] }>
209
+
176
210
  /**
177
211
  * Transcrição completa gerada pelo servidor. Existe ao lado de `buildTranscriptText`, que monta
178
212
  * a partir das mensagens já em memória: a tela costuma ter só a última página carregada, e
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Seção de anexos do formulário de cadastro (QR-31), extraída de `QuickRepliesWorkspace`: lista de
3
+ * anexos já subidos (com reordenação), uploads em voo e recusas de arquivo. Some inteira quando o
4
+ * host não oferece `uploadQuickReplyAttachment` — a mesma regra de capacidade do resto do pacote.
5
+ */
6
+
7
+ import { useRef } from 'react'
8
+ import { Paperclip, X } from 'lucide-react'
9
+ import { formatFileSize } from '../lib/format'
10
+ import type { PendingAttachmentUpload } from './useQuickReplyAttachmentUploads'
11
+ import type { AttachmentFileRejection } from './quickReplyAttachmentUpload'
12
+ import type { QuickReplyAttachment } from './quickReply.types'
13
+ import type { QuickRepliesWorkspaceLabels } from './labels'
14
+
15
+ export type AttachmentsFormSectionProps = {
16
+ readonly labels: QuickRepliesWorkspaceLabels
17
+ readonly attachments: readonly QuickReplyAttachment[]
18
+ readonly pendingUploads: readonly PendingAttachmentUpload[]
19
+ readonly attachmentRejections: readonly AttachmentFileRejection[]
20
+ readonly onAddFiles: (files: FileList) => void
21
+ readonly onRetryUpload: (localId: string) => void
22
+ readonly onCancelUpload: (localId: string) => void
23
+ readonly onRemoveAttachment: (uploadId: string) => void
24
+ readonly onMoveAttachment: (index: number, direction: -1 | 1) => void
25
+ readonly onDismissRejections: () => void
26
+ }
27
+
28
+ export function AttachmentsFormSection({
29
+ labels: text,
30
+ attachments,
31
+ pendingUploads,
32
+ attachmentRejections,
33
+ onAddFiles,
34
+ onRetryUpload,
35
+ onCancelUpload,
36
+ onRemoveAttachment,
37
+ onMoveAttachment,
38
+ onDismissRejections,
39
+ }: AttachmentsFormSectionProps) {
40
+ const attachmentFileInputRef = useRef<HTMLInputElement>(null)
41
+
42
+ return (
43
+ <div className="space-y-2">
44
+ <span className="block text-sm font-medium">{text.attachmentsTitle}</span>
45
+
46
+ {attachments.length === 0 && pendingUploads.length === 0 ? (
47
+ <p className="text-xs text-gray-500 dark:text-gray-400">{text.attachmentsEmpty}</p>
48
+ ) : (
49
+ <ul className="space-y-1">
50
+ {attachments.map((attachment, index) => (
51
+ <li
52
+ key={attachment.uploadId}
53
+ className="flex items-center gap-2 rounded-md border border-gray-200 px-2 py-1.5 text-xs dark:border-gray-700"
54
+ >
55
+ <Paperclip aria-hidden="true" className="h-3.5 w-3.5 flex-none text-gray-400" />
56
+ <span className="min-w-0 flex-1 truncate">{attachment.filename}</span>
57
+ <span className="flex-none text-gray-400">{formatFileSize(attachment.sizeBytes)}</span>
58
+ <button
59
+ type="button"
60
+ disabled={index === 0}
61
+ aria-label={text.attachmentMoveUp}
62
+ onClick={() => onMoveAttachment(index, -1)}
63
+ className="flex-none disabled:opacity-30"
64
+ >
65
+
66
+ </button>
67
+ <button
68
+ type="button"
69
+ disabled={index === attachments.length - 1}
70
+ aria-label={text.attachmentMoveDown}
71
+ onClick={() => onMoveAttachment(index, 1)}
72
+ className="flex-none disabled:opacity-30"
73
+ >
74
+
75
+ </button>
76
+ <button
77
+ type="button"
78
+ aria-label={text.attachmentRemove}
79
+ onClick={() => onRemoveAttachment(attachment.uploadId)}
80
+ className="flex-none text-red-600 dark:text-red-400"
81
+ >
82
+ <X aria-hidden="true" className="h-3.5 w-3.5" />
83
+ </button>
84
+ </li>
85
+ ))}
86
+ {pendingUploads.map((pending) => (
87
+ <li
88
+ key={pending.localId}
89
+ className="flex items-center gap-2 rounded-md border border-gray-200 px-2 py-1.5 text-xs dark:border-gray-700"
90
+ aria-busy={pending.status === 'uploading'}
91
+ aria-live="polite"
92
+ >
93
+ <Paperclip aria-hidden="true" className="h-3.5 w-3.5 flex-none text-gray-400" />
94
+ <span className="min-w-0 flex-1 truncate">{pending.file.name}</span>
95
+ {pending.status === 'uploading' ? (
96
+ <span className="flex-none text-gray-500 dark:text-gray-400">
97
+ {text.attachmentUploading(Math.round(pending.progress * 100))}
98
+ </span>
99
+ ) : (
100
+ <>
101
+ <span role="alert" className="flex-none text-red-600 dark:text-red-400">
102
+ {pending.error}
103
+ </span>
104
+ <button
105
+ type="button"
106
+ onClick={() => onRetryUpload(pending.localId)}
107
+ className="flex-none font-medium text-blue-600 hover:underline dark:text-blue-400"
108
+ >
109
+ {text.attachmentRetry}
110
+ </button>
111
+ </>
112
+ )}
113
+ <button
114
+ type="button"
115
+ aria-label={text.attachmentCancel}
116
+ onClick={() => onCancelUpload(pending.localId)}
117
+ className="flex-none text-gray-400"
118
+ >
119
+ <X aria-hidden="true" className="h-3.5 w-3.5" />
120
+ </button>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ )}
125
+
126
+ {attachmentRejections.length > 0 ? (
127
+ <div role="alert" className="space-y-0.5 text-xs text-red-600 dark:text-red-400">
128
+ {attachmentRejections.map((rejection, index) => (
129
+ <p key={index}>
130
+ {rejection.reason === 'limit'
131
+ ? text.attachmentLimitReached
132
+ : text.attachmentTooLarge(rejection.file.name)}
133
+ </p>
134
+ ))}
135
+ <button type="button" onClick={onDismissRejections} className="hover:underline">
136
+ {text.cancel}
137
+ </button>
138
+ </div>
139
+ ) : null}
140
+
141
+ <input
142
+ ref={attachmentFileInputRef}
143
+ type="file"
144
+ multiple
145
+ hidden
146
+ onChange={(event) => {
147
+ if (event.target.files) onAddFiles(event.target.files)
148
+ event.target.value = ''
149
+ }}
150
+ />
151
+ <button
152
+ type="button"
153
+ onClick={() => attachmentFileInputRef.current?.click()}
154
+ className="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
155
+ >
156
+ {text.attachmentsAdd}
157
+ </button>
158
+ </div>
159
+ )
160
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { QuickRepliesPicker } from './QuickRepliesPicker'
5
+ import type { QuickReply } from './quickReply.types'
6
+
7
+ const ITEMS: QuickReply[] = [
8
+ { id: '1', title: 'Saudação', shortcut: 'ola', body: 'Olá!' },
9
+ { id: '2', title: 'Pedido de documento', shortcut: 'doc', body: 'Envie o RG.' },
10
+ ]
11
+
12
+ const GREETING_WITH_VARIABLE: QuickReply = {
13
+ id: '3',
14
+ title: 'Boas-vindas',
15
+ shortcut: 'bv',
16
+ body: 'Olá {{nome}}, bem-vindo!',
17
+ }
18
+
19
+ describe('QuickRepliesPicker', () => {
20
+ it('mostra o estado vazio quando não há mensagens cadastradas', () => {
21
+ const markup = renderToStaticMarkup(
22
+ <QuickRepliesPicker
23
+ id="qr"
24
+ items={[]}
25
+ search=""
26
+ highlightedIndex={0}
27
+ isLoading={false}
28
+ onHover={() => {}}
29
+ onSelect={() => {}}
30
+ />,
31
+ )
32
+ expect(markup).toContain('Nenhuma mensagem pronta cadastrada ainda.')
33
+ })
34
+
35
+ it('mostra o estado de nenhum resultado quando a busca não acha nada', () => {
36
+ const markup = renderToStaticMarkup(
37
+ <QuickRepliesPicker
38
+ id="qr"
39
+ items={[]}
40
+ search="boleto"
41
+ highlightedIndex={0}
42
+ isLoading={false}
43
+ onHover={() => {}}
44
+ onSelect={() => {}}
45
+ />,
46
+ )
47
+ expect(markup).toContain('Nenhuma mensagem encontrada.')
48
+ })
49
+
50
+ it('mostra o carregamento antes da lista', () => {
51
+ const markup = renderToStaticMarkup(
52
+ <QuickRepliesPicker
53
+ id="qr"
54
+ items={[]}
55
+ search=""
56
+ highlightedIndex={0}
57
+ isLoading
58
+ onHover={() => {}}
59
+ onSelect={() => {}}
60
+ />,
61
+ )
62
+ expect(markup).toContain('Carregando mensagens prontas')
63
+ })
64
+
65
+ it('marca a opção destacada com aria-selected', () => {
66
+ const markup = renderToStaticMarkup(
67
+ <QuickRepliesPicker
68
+ id="qr"
69
+ items={ITEMS}
70
+ search=""
71
+ highlightedIndex={1}
72
+ isLoading={false}
73
+ onHover={() => {}}
74
+ onSelect={() => {}}
75
+ />,
76
+ )
77
+ expect(markup).toContain('id="qr-option-1"')
78
+ expect(markup).toMatch(/id="qr-option-1"[^>]*aria-selected="true"/)
79
+ expect(markup).toMatch(/id="qr-option-0"[^>]*aria-selected="false"/)
80
+ })
81
+
82
+ it('destaca o trecho buscado sem produzir HTML a partir do texto cadastrado', () => {
83
+ const markup = renderToStaticMarkup(
84
+ <QuickRepliesPicker
85
+ id="qr"
86
+ items={ITEMS}
87
+ search="doc"
88
+ highlightedIndex={0}
89
+ isLoading={false}
90
+ onHover={() => {}}
91
+ onSelect={() => {}}
92
+ />,
93
+ )
94
+ expect(markup).toContain('<mark')
95
+ expect(markup).toContain('Pedido de ')
96
+ })
97
+
98
+ it('mostra a prévia do corpo com a variável já resolvida (QR-04), nunca o marcador cru', () => {
99
+ const markup = renderToStaticMarkup(
100
+ <QuickRepliesPicker
101
+ id="qr"
102
+ items={[GREETING_WITH_VARIABLE]}
103
+ search=""
104
+ highlightedIndex={0}
105
+ isLoading={false}
106
+ onHover={() => {}}
107
+ onSelect={() => {}}
108
+ variables={{ nome: 'João' }}
109
+ />,
110
+ )
111
+ expect(markup).toContain('Olá João, bem-vindo!')
112
+ expect(markup).not.toContain('{{nome}}')
113
+ })
114
+ })