@adatechnology/conversations-ui 0.4.2 → 0.6.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 (110) hide show
  1. package/README.md +231 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. package/src/types.ts +3 -1
@@ -14,6 +14,8 @@ export const CHANNEL_BRAND_COLOR: Readonly<Record<ConversationChannel, string>>
14
14
  [CONVERSATION_CHANNEL.WHATSAPP]: '#25D366',
15
15
  [CONVERSATION_CHANNEL.MESSENGER]: '#0084FF',
16
16
  [CONVERSATION_CHANNEL.INSTAGRAM]: '#E4405F',
17
+ [CONVERSATION_CHANNEL.APP]: '#7C3AED',
18
+ [CONVERSATION_CHANNEL.PORTAL]: '#0F766E',
17
19
  [CONVERSATION_CHANNEL.WEBCHAT]: '#64748B',
18
20
  [CONVERSATION_CHANNEL.EMAIL]: '#EA4335',
19
21
  }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { MessageText } from './MessageText'
5
+ import { MessageTextCopiedBadge } from './MessageTextCopiedBadge'
6
+ import type { MessagePayload } from './types'
7
+
8
+ const TEXT_MESSAGE: MessagePayload = {
9
+ id: 'msg-1',
10
+ type: 'text',
11
+ direction: 'inbound',
12
+ sender: 'customer',
13
+ timestamp: '2026-08-05T12:00:00.000Z',
14
+ content: 'Olá *mundo*',
15
+ }
16
+
17
+ describe('MessageText', () => {
18
+ it('com appearance="stylesheet" usa a classe cv-message-text e nenhuma utilitária Tailwind', () => {
19
+ const markup = renderToStaticMarkup(<MessageText message={TEXT_MESSAGE} appearance="stylesheet" />)
20
+
21
+ expect(markup).toContain('cv-message-text')
22
+ expect(markup).not.toContain('whitespace-pre-wrap')
23
+ expect(markup).not.toContain('select-all')
24
+ expect(markup).not.toContain('text-[')
25
+ expect(markup).not.toContain('[&_')
26
+ })
27
+
28
+ it('o badge em stylesheet usa a classe cv-message-text__copied e nenhuma utilitária Tailwind', () => {
29
+ const markup = renderToStaticMarkup(<MessageTextCopiedBadge appearance="stylesheet" />)
30
+
31
+ expect(markup).toContain('cv-message-text__copied')
32
+ expect(markup).not.toContain('absolute')
33
+ expect(markup).not.toContain('bg-[')
34
+ expect(markup).not.toContain('px-1.5')
35
+ expect(markup).not.toContain('-translate-y-full')
36
+ })
37
+
38
+ it('por padrão é o div da 0.4.2: classes Tailwind, sem role nem tabindex', () => {
39
+ const markup = renderToStaticMarkup(<MessageText message={TEXT_MESSAGE} />)
40
+
41
+ expect(markup).toContain('select-all')
42
+ expect(markup).toContain('whitespace-pre-wrap')
43
+ expect(markup).not.toContain('role=')
44
+ expect(markup).not.toContain('tabindex')
45
+ expect(markup).not.toContain('cv-message-text')
46
+ })
47
+
48
+ it('com accessibleCopy liga role=button e tabindex', () => {
49
+ const markup = renderToStaticMarkup(<MessageText message={TEXT_MESSAGE} accessibleCopy />)
50
+
51
+ expect(markup).toContain('role="button"')
52
+ expect(markup).toContain('tabindex="0"')
53
+ })
54
+
55
+ it('em stylesheet a classe copiável (user-select) só aparece quando copia', () => {
56
+ const markup = renderToStaticMarkup(<MessageText message={TEXT_MESSAGE} appearance="stylesheet" />)
57
+
58
+ expect(markup).toContain('cv-message-text--copyable')
59
+ })
60
+
61
+ it('com copyOnClick={false} em stylesheet não copia: sem role, sem tabindex e sem classe copiável', () => {
62
+ const markup = renderToStaticMarkup(
63
+ <MessageText message={TEXT_MESSAGE} copyOnClick={false} accessibleCopy appearance="stylesheet" />,
64
+ )
65
+
66
+ expect(markup).not.toContain('role="button"')
67
+ expect(markup).not.toContain('tabindex')
68
+ expect(markup).not.toContain('cv-message-text--copyable')
69
+ expect(markup).toContain('class="cv-message-text"')
70
+ })
71
+
72
+ it('o badge usa "Copiado!" por padrão e aceita outro rótulo', () => {
73
+ expect(renderToStaticMarkup(<MessageTextCopiedBadge />)).toContain('Copiado!')
74
+ expect(renderToStaticMarkup(<MessageTextCopiedBadge label="Copied" />)).toContain('Copied')
75
+ })
76
+ })
@@ -1,41 +1,57 @@
1
1
  import { useCallback, useState } from 'react'
2
2
  import { CHAT_TEXT_SECONDARY_CLASS } from './theme'
3
3
  import type { MessagePayload } from './types'
4
- import { useConversations } from './providers/ConversationsProvider'
5
4
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
5
+ import { resolveCopyInteraction } from './messageTextCopyInteraction'
6
+ import { MessageTextCopiedBadge, type MessageTextAppearance } from './MessageTextCopiedBadge'
6
7
 
7
8
  export interface MessageTextProps {
8
9
  message: MessagePayload
10
+ /** Defaults to true (tap copies the text, as in 0.4.2); false disables copying. */
11
+ copyOnClick?: boolean
12
+ /** Opt-in: exposes the copy target as a keyboard-operable button (role, tabIndex, Enter/Space). Defaults to false. */
13
+ accessibleCopy?: boolean
14
+ /** Text of the badge shown after copying. Defaults to 'Copiado!', as in 0.4.2. */
15
+ copiedLabel?: string
16
+ /** 'tailwind' (default) keeps the 0.4.2 utility classes; 'stylesheet' uses the .cv-* rules from styles.css. */
17
+ appearance?: MessageTextAppearance
9
18
  }
10
19
 
11
- export function MessageText({ message }: MessageTextProps) {
20
+ const TAILWIND_CLASS =
21
+ 'text-[14.2px] leading-[19px] whitespace-pre-wrap break-words select-all [&_strong]:font-bold [&_em]:italic [&_del]:line-through'
22
+
23
+ function resolveClassName(appearance: MessageTextAppearance, copyOnClick: boolean): string {
24
+ if (appearance === 'tailwind') return TAILWIND_CLASS
25
+ return copyOnClick ? 'cv-message-text cv-message-text--copyable' : 'cv-message-text'
26
+ }
27
+
28
+ export function MessageText({
29
+ message, copyOnClick = true, accessibleCopy = false, copiedLabel, appearance = 'tailwind',
30
+ }: MessageTextProps) {
12
31
  const [copied, setCopied] = useState(false)
13
32
 
14
33
  const handleCopy = useCallback(async () => {
15
- if (!message.content) return
34
+ if (!copyOnClick || !message.content) return
16
35
  try {
17
36
  await navigator.clipboard.writeText(message.content)
18
37
  setCopied(true)
19
38
  setTimeout(() => setCopied(false), 2000)
20
39
  } catch {
21
- // Clipboard not available
22
40
  }
23
- }, [message.content])
41
+ }, [copyOnClick, message.content])
24
42
 
25
43
  if (message.type === 'template')
26
44
  return <p className={`text-sm italic ${CHAT_TEXT_SECONDARY_CLASS}`}>{message.content ?? 'Template message'}</p>
27
45
 
46
+ const interactionProps = resolveCopyInteraction({ copyOnClick, accessibleCopy, onCopy: handleCopy })
47
+
28
48
  return (
29
49
  <div
30
- onClick={handleCopy}
31
- className="text-[14.2px] leading-[19px] whitespace-pre-wrap break-words select-all [&_strong]:font-bold [&_em]:italic [&_del]:line-through"
50
+ className={resolveClassName(appearance, copyOnClick)}
51
+ {...interactionProps}
32
52
  >
33
53
  <div>{parseWhatsAppFormatting(message.content ?? '')}</div>
34
- {copied && (
35
- <span className="absolute top-0 right-0 -translate-y-full bg-[#3b4a54] text-white text-[11px] px-1.5 py-0.5 rounded shadow-lg">
36
- Copiado!
37
- </span>
38
- )}
54
+ {copied && <MessageTextCopiedBadge label={copiedLabel} appearance={appearance} />}
39
55
  </div>
40
56
  )
41
57
  }
@@ -0,0 +1,15 @@
1
+ export type MessageTextAppearance = 'tailwind' | 'stylesheet'
2
+
3
+ export interface MessageTextCopiedBadgeProps {
4
+ label?: string
5
+ /** 'tailwind' (default) keeps the 0.4.2 utility classes; 'stylesheet' uses the .cv-* rules from styles.css. */
6
+ appearance?: MessageTextAppearance
7
+ }
8
+
9
+ const TAILWIND_CLASS =
10
+ 'absolute top-0 right-0 -translate-y-full bg-[#3b4a54] text-white text-[11px] px-1.5 py-0.5 rounded shadow-lg'
11
+
12
+ export function MessageTextCopiedBadge({ label = 'Copiado!', appearance = 'tailwind' }: MessageTextCopiedBadgeProps) {
13
+ const className = appearance === 'stylesheet' ? 'cv-message-text__copied' : TAILWIND_CLASS
14
+ return <span className={className}>{label}</span>
15
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { StatusTicks } from './StatusTicks'
5
+
6
+ const TAILWIND_UTILITIES = ['text-sky-500', 'text-red-500', 'text-black/40', 'flex', 'cursor-help', 'leading-none']
7
+
8
+ describe('StatusTicks', () => {
9
+ it('em stylesheet um status desconhecido cai em sent (um tique)', () => {
10
+ const markup = renderToStaticMarkup(<StatusTicks status="xyz" appearance="stylesheet" />)
11
+
12
+ expect(markup).toContain('cv-status-ticks--sent')
13
+ })
14
+
15
+ it('em stylesheet renderiza o estado queued com relógio e rótulo acessível', () => {
16
+ const markup = renderToStaticMarkup(<StatusTicks status="queued" appearance="stylesheet" />)
17
+
18
+ expect(markup).toContain('cv-status-ticks--queued')
19
+ expect(markup).toContain('aria-label="Queued"')
20
+ })
21
+
22
+ it('em stylesheet o rótulo de queued é configurável', () => {
23
+ const markup = renderToStaticMarkup(<StatusTicks status="queued" appearance="stylesheet" queuedLabel="Na fila" />)
24
+
25
+ expect(markup).toContain('aria-label="Na fila"')
26
+ })
27
+
28
+ it('em stylesheet trata bounced como failed', () => {
29
+ const markup = renderToStaticMarkup(<StatusTicks status="bounced" appearance="stylesheet" />)
30
+
31
+ expect(markup).toContain('cv-status-ticks--failed')
32
+ expect(markup).not.toContain('cv-status-ticks--bounced')
33
+ })
34
+
35
+ it('por padrão mantém as utilitárias Tailwind e nenhum aria-label (0.4.2)', () => {
36
+ const markup = renderToStaticMarkup(<StatusTicks status="read" />)
37
+
38
+ expect(markup).toContain('text-sky-500')
39
+ expect(markup).toContain('cursor-help')
40
+ expect(markup).not.toContain('cv-status-ticks')
41
+ expect(markup).not.toContain('aria-label')
42
+ })
43
+
44
+ it.each(['sent', 'delivered', 'read', 'failed', 'queued'])('em stylesheet usa só classes cv-* em %s', (status) => {
45
+ const markup = renderToStaticMarkup(<StatusTicks status={status} appearance="stylesheet" />)
46
+
47
+ expect(markup).toContain(`cv-status-ticks--${status}`)
48
+ for (const utility of TAILWIND_UTILITIES) expect(markup).not.toContain(utility)
49
+ expect(markup).not.toContain('text-[')
50
+ })
51
+ })
@@ -1,17 +1,30 @@
1
- import { AlertTriangle } from 'lucide-react'
1
+ import { AlertTriangle, Clock } from 'lucide-react'
2
2
 
3
3
  export interface StatusTicksProps {
4
4
  status: string
5
5
  title?: string
6
+ /** 'tailwind' (default) keeps the 0.4.2 markup; 'stylesheet' uses .cv-status-ticks--* and adds queued/bounced handling. */
7
+ appearance?: 'tailwind' | 'stylesheet'
8
+ /** Accessible label of the queued state (stylesheet appearance only). Defaults to 'Queued'. */
9
+ queuedLabel?: string
6
10
  }
7
11
 
8
- const STATUS_COLOR_CLASS: Record<string, string> = {
12
+ const TAILWIND_COLOR_CLASS: Record<string, string> = {
9
13
  sent: 'text-black/40 dark:text-white/40',
10
14
  delivered: 'text-black/40 dark:text-white/40',
11
15
  read: 'text-sky-500',
12
16
  failed: 'text-red-500',
13
17
  }
14
18
 
19
+ const STYLESHEET_MODIFIER: Record<string, string> = {
20
+ queued: 'queued',
21
+ sent: 'sent',
22
+ delivered: 'delivered',
23
+ read: 'read',
24
+ failed: 'failed',
25
+ bounced: 'failed',
26
+ }
27
+
15
28
  function Ticks({ double }: { double: boolean }) {
16
29
  return (
17
30
  <svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -26,10 +39,27 @@ function Ticks({ double }: { double: boolean }) {
26
39
  )
27
40
  }
28
41
 
29
- // Paridade com financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx —
30
- // mesmo path de SVG, mesma cor por status (read → sky-500, failed → red-500 com AlertTriangle).
31
- export function StatusTicks({ status, title }: StatusTicksProps) {
32
- const colorClass = STATUS_COLOR_CLASS[status] ?? STATUS_COLOR_CLASS.sent
42
+ function StylesheetStatusTicks({ status, title, queuedLabel }: { status: string; title?: string; queuedLabel: string }) {
43
+ const modifier = STYLESHEET_MODIFIER[status] ?? 'sent'
44
+ const ariaLabel = modifier === 'queued' ? queuedLabel : undefined
45
+ const icon =
46
+ modifier === 'failed' ? <AlertTriangle size={11} /> : modifier === 'queued' ? <Clock size={11} /> : <Ticks double={modifier !== 'sent'} />
47
+
48
+ return (
49
+ <span
50
+ className={`cv-status-ticks cv-status-ticks--${modifier}`}
51
+ data-cv-tooltip={title ?? ariaLabel}
52
+ aria-label={ariaLabel}
53
+ >
54
+ {icon}
55
+ </span>
56
+ )
57
+ }
58
+
59
+ export function StatusTicks({ status, title, appearance = 'tailwind', queuedLabel = 'Queued' }: StatusTicksProps) {
60
+ if (appearance === 'stylesheet') return <StylesheetStatusTicks status={status} title={title} queuedLabel={queuedLabel} />
61
+
62
+ const colorClass = TAILWIND_COLOR_CLASS[status] ?? TAILWIND_COLOR_CLASS.sent
33
63
 
34
64
  return (
35
65
  <span className={`cursor-help leading-none flex items-center ${colorClass}`} data-cv-tooltip={title}>
@@ -77,3 +77,44 @@ describe('o bundle do host não paga por @xyflow/react sem pedir', () => {
77
77
  expect(await distText('index.js')).not.toContain('@xyflow/react')
78
78
  })
79
79
  })
80
+
81
+ describe('a visão do participante é leve e isolada', () => {
82
+ const PARTICIPANT_ENTRY_LIMIT_BYTES = 250 * 1024
83
+
84
+ async function participantBundle(): Promise<string> {
85
+ // O entry mais os chunks relativos que ele importa, transitivamente: é o que o host baixa.
86
+ const seen = new Set<string>()
87
+ const queue = ['participant/index.js']
88
+ let total = ''
89
+ while (queue.length > 0) {
90
+ const file = queue.shift() as string
91
+ if (seen.has(file)) continue
92
+ seen.add(file)
93
+ const text = await distText(file)
94
+ total += text
95
+ for (const match of text.matchAll(/(?:from|import)\s*"(\.[^"]+\.js)"/g)) {
96
+ const target = new URL(match[1] as string, `file:///${file}`).pathname.slice(1)
97
+ queue.push(target)
98
+ }
99
+ }
100
+ return total
101
+ }
102
+
103
+ it('não puxa o xyflow nem o ConversationsWorkspace', async () => {
104
+ const bundle = await participantBundle()
105
+
106
+ expect(bundle).not.toContain('xyflow')
107
+ expect(bundle).not.toContain('ConversationsWorkspace')
108
+ })
109
+
110
+ it('fica abaixo de 250 KB com os chunks que importa', async () => {
111
+ const bundle = await participantBundle()
112
+
113
+ expect(new TextEncoder().encode(bundle).length).toBeLessThan(PARTICIPANT_ENTRY_LIMIT_BYTES)
114
+ })
115
+
116
+ it('entrega a tela e os tipos no subpath /participant', async () => {
117
+ expect(await distText('participant/index.js')).toContain('ParticipantConversations')
118
+ expect(await distText('participant/index.d.ts')).toContain('ParticipantConversations')
119
+ })
120
+ })
@@ -45,3 +45,20 @@ describe('linhas da capacidade que a tela não pode fingir', () => {
45
45
  expect(CORE_CHANNEL_CAPABILITIES.portal.audio).toEqual({ plays: true, records: false })
46
46
  })
47
47
  })
48
+
49
+ // App e portal são canais do contrato que a UI passou a exibir (T1.0a): a capacidade vem do
50
+ // contrato, nunca da regra do WhatsApp, que daria janela de 24h e 100 MB onde não existem.
51
+ describe('canais app e portal', () => {
52
+ it('app: anexo de 25 MB, grava áudio e não tem janela de sessão', () => {
53
+ const capability = channelCapabilityFor(CONVERSATION_CHANNEL.APP)
54
+
55
+ expect(capability).toBe(CORE_CHANNEL_CAPABILITIES.app)
56
+ expect(capability.attachments.maxBytes).toBe(25 * 1024 * 1024)
57
+ expect(capability.audio.records).toBe(true)
58
+ expect(capability.sessionWindowHours).toBeNull()
59
+ })
60
+
61
+ it('portal: ouve áudio e não grava', () => {
62
+ expect(channelCapabilityFor(CONVERSATION_CHANNEL.PORTAL).audio.records).toBe(false)
63
+ })
64
+ })
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { CONVERSATION_CHANNEL, CHANNEL_FILTER_ALL, channelFiltersFor } from './conversationChannel'
2
+ import { CONVERSATION_CHANNEL as CORE_CONVERSATION_CHANNEL } from '@adatechnology/conversation-contracts'
3
+
4
+ import {
5
+ CHANNEL_CAPABILITIES,
6
+ CHANNEL_FILTER_ALL,
7
+ CONVERSATION_CHANNEL,
8
+ HANDLE_KIND,
9
+ REOPEN_MECHANISM,
10
+ channelFiltersFor,
11
+ type ConversationChannel,
12
+ } from './conversationChannel'
3
13
 
4
14
  describe('channelFiltersFor', () => {
5
15
  // O ponto do helper: a barra oferece só o que existe. Oferecer Instagram numa conta que só tem
@@ -51,3 +61,22 @@ describe('channelFiltersFor', () => {
51
61
  expect(first).toEqual(second)
52
62
  })
53
63
  })
64
+
65
+ describe('canais do contrato exibidos pela UI', () => {
66
+ // Canal novo no contrato sem entrada aqui reprova no tsc (Record completo); este teste cobre o
67
+ // mesmo ponto em runtime, para o caso de o vocabulário ser alterado sem recompilar a UI.
68
+ it('todo canal do contrato tem entrada de exibição', () => {
69
+ for (const channel of CORE_CONVERSATION_CHANNEL) {
70
+ expect(CHANNEL_CAPABILITIES[channel as ConversationChannel]).toBeDefined()
71
+ }
72
+ })
73
+
74
+ it('app e portal existem como canais, com rótulo e sem reabertura por template ou tag', () => {
75
+ expect(CONVERSATION_CHANNEL.APP).toBe('app')
76
+ expect(CONVERSATION_CHANNEL.PORTAL).toBe('portal')
77
+ expect(CHANNEL_CAPABILITIES.app.reopenMechanism).toBe(REOPEN_MECHANISM.NONE)
78
+ expect(CHANNEL_CAPABILITIES.portal.reopenMechanism).toBe(REOPEN_MECHANISM.NONE)
79
+ expect(CHANNEL_CAPABILITIES.app.handleKind).toBe(HANDLE_KIND.SESSION)
80
+ expect(CHANNEL_CAPABILITIES.portal.handleKind).toBe(HANDLE_KIND.SESSION)
81
+ })
82
+ })
@@ -10,16 +10,22 @@
10
10
  * funcionando exatamente como antes.
11
11
  */
12
12
 
13
+ import type { ConversationChannel as CoreConversationChannel } from '@adatechnology/conversation-contracts'
14
+
13
15
  import { formatPhone, phoneCountryFlag } from './lib/phone'
14
16
 
15
17
  export const CONVERSATION_CHANNEL = {
16
18
  WHATSAPP: 'whatsapp',
17
19
  MESSENGER: 'messenger',
18
20
  INSTAGRAM: 'instagram',
21
+ APP: 'app',
22
+ PORTAL: 'portal',
19
23
  WEBCHAT: 'webchat',
20
24
  EMAIL: 'email',
21
25
  } as const
22
- export type ConversationChannel = (typeof CONVERSATION_CHANNEL)[keyof typeof CONVERSATION_CHANNEL]
26
+
27
+ /** O que o contrato conhece, mais os dois canais da Meta que a UI já servia antes dele. */
28
+ export type ConversationChannel = CoreConversationChannel | 'messenger' | 'instagram'
23
29
 
24
30
  export const DEFAULT_CONVERSATION_CHANNEL: ConversationChannel = CONVERSATION_CHANNEL.WHATSAPP
25
31
 
@@ -75,6 +81,18 @@ export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelC
75
81
  reopenMechanism: REOPEN_MECHANISM.TAG,
76
82
  handleKind: HANDLE_KIND.USERNAME,
77
83
  },
84
+ [CONVERSATION_CHANNEL.APP]: {
85
+ label: 'App',
86
+ icon: '📱',
87
+ reopenMechanism: REOPEN_MECHANISM.NONE,
88
+ handleKind: HANDLE_KIND.SESSION,
89
+ },
90
+ [CONVERSATION_CHANNEL.PORTAL]: {
91
+ label: 'Portal',
92
+ icon: '🔗',
93
+ reopenMechanism: REOPEN_MECHANISM.NONE,
94
+ handleKind: HANDLE_KIND.SESSION,
95
+ },
78
96
  [CONVERSATION_CHANNEL.WEBCHAT]: {
79
97
  // Chat próprio: sem intermediário, sem janela. Bloquear o composer aqui seria inventar limite.
80
98
  label: 'Chat do site',
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ChannelCapability } from '@adatechnology/conversation-contracts'
4
+
5
+ import { channelCapabilityFor } from './channelCapability'
6
+ import { CHANNEL_FILTER_ALL, channelFiltersFor, type ConversationChannel } from './conversationChannel'
7
+
8
+ const MEGABYTE = 1024 * 1024
9
+
10
+ // Values published by conversation-contracts 0.3.0 (b9f1ef6), read by the 0.4.2 UI.
11
+ const LEGACY_WHATSAPP_CAPABILITY: ChannelCapability = {
12
+ confirmsRead: true,
13
+ sessionWindowHours: 24,
14
+ attachments: { accepted: true, maxBytes: 100 * MEGABYTE, maxTotalBytes: null },
15
+ audio: { plays: true, records: true },
16
+ quickReplies: true,
17
+ requiresTransport: false,
18
+ reachableStatuses: ['queued', 'sent', 'delivered', 'read', 'failed'],
19
+ }
20
+
21
+ const LEGACY_EMAIL_CAPABILITY: ChannelCapability = {
22
+ confirmsRead: false,
23
+ sessionWindowHours: null,
24
+ attachments: { accepted: true, maxBytes: 10 * MEGABYTE, maxTotalBytes: 25 * MEGABYTE },
25
+ audio: { plays: true, records: true },
26
+ quickReplies: true,
27
+ requiresTransport: true,
28
+ reachableStatuses: ['queued', 'sent', 'delivered', 'failed', 'bounced'],
29
+ }
30
+
31
+ const LEGACY_WEBCHAT_CAPABILITY: ChannelCapability = {
32
+ confirmsRead: false,
33
+ sessionWindowHours: null,
34
+ attachments: { accepted: true, maxBytes: 10 * MEGABYTE, maxTotalBytes: null },
35
+ audio: { plays: false, records: false },
36
+ quickReplies: true,
37
+ requiresTransport: false,
38
+ reachableStatuses: ['queued', 'delivered', 'failed'],
39
+ }
40
+
41
+ describe('channelCapabilityFor mantém os valores da 0.4.2', () => {
42
+ it('G9: whatsapp', () => {
43
+ expect(channelCapabilityFor('whatsapp')).toEqual(LEGACY_WHATSAPP_CAPABILITY)
44
+ })
45
+
46
+ it('G9: email', () => {
47
+ expect(channelCapabilityFor('email')).toEqual(LEGACY_EMAIL_CAPABILITY)
48
+ })
49
+
50
+ it('G9: webchat', () => {
51
+ expect(channelCapabilityFor('webchat')).toEqual(LEGACY_WEBCHAT_CAPABILITY)
52
+ })
53
+
54
+ it('G9: messenger cai na capacidade do WhatsApp', () => {
55
+ expect(channelCapabilityFor('messenger')).toEqual(LEGACY_WHATSAPP_CAPABILITY)
56
+ })
57
+
58
+ it('G9: instagram cai na capacidade do WhatsApp', () => {
59
+ expect(channelCapabilityFor('instagram')).toEqual(LEGACY_WHATSAPP_CAPABILITY)
60
+ })
61
+
62
+ it('G9: canal ausente cai na capacidade do WhatsApp', () => {
63
+ expect(channelCapabilityFor(undefined)).toEqual(LEGACY_WHATSAPP_CAPABILITY)
64
+ })
65
+ })
66
+
67
+ describe('channelFiltersFor com canais antigos mantém lista e ordem da 0.4.2', () => {
68
+ it('G9: os cinco canais antigos saem na ordem do catálogo, não na de chegada', () => {
69
+ const channels: ConversationChannel[] = ['email', 'webchat', 'instagram', 'messenger', 'whatsapp']
70
+
71
+ expect(channelFiltersFor(channels.map((channel) => ({ channel })))).toEqual([
72
+ { value: CHANNEL_FILTER_ALL, label: 'Todos' },
73
+ { value: 'whatsapp', label: 'WhatsApp' },
74
+ { value: 'messenger', label: 'Messenger' },
75
+ { value: 'instagram', label: 'Instagram' },
76
+ { value: 'webchat', label: 'Chat do site' },
77
+ { value: 'email', label: 'E-mail' },
78
+ ])
79
+ })
80
+
81
+ it('G9: conversa sem canal conta como WhatsApp e um canal só não gera filtro', () => {
82
+ expect(channelFiltersFor([{}, { channel: 'whatsapp' }])).toEqual([])
83
+ expect(channelFiltersFor([{}, { channel: 'email' }])).toEqual([
84
+ { value: CHANNEL_FILTER_ALL, label: 'Todos' },
85
+ { value: 'whatsapp', label: 'WhatsApp' },
86
+ { value: 'email', label: 'E-mail' },
87
+ ])
88
+ })
89
+ })
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { MessageBubble } from './MessageBubble'
5
+ import { MessageText } from './MessageText'
6
+ import { MessageTextCopiedBadge } from './MessageTextCopiedBadge'
7
+ import { StatusTicks } from './StatusTicks'
8
+ import type { MessagePayload } from './types'
9
+
10
+ // Literal markup rendered by @adatechnology/conversations-ui 0.4.2 (b9f1ef6): the default appearance of
11
+ // these shared components must never drift, because hosts upgrade without touching their markup.
12
+ const LEGACY_TEXT_MARKUP =
13
+ '<div class="text-[14.2px] leading-[19px] whitespace-pre-wrap break-words select-all [&amp;_strong]:font-bold [&amp;_em]:italic [&amp;_del]:line-through"><div><strong>oi</strong></div></div>'
14
+
15
+ const LEGACY_TEMPLATE_MARKUP = '<p class="text-sm italic text-[#667781] dark:text-[#8696a0]">Olá modelo</p>'
16
+
17
+ const LEGACY_TICKS_SENT =
18
+ '<span class="cursor-help leading-none flex items-center text-black/40 dark:text-white/40"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>'
19
+
20
+ const LEGACY_TICKS_DELIVERED =
21
+ '<span class="cursor-help leading-none flex items-center text-black/40 dark:text-white/40"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>'
22
+
23
+ const LEGACY_TICKS_READ =
24
+ '<span class="cursor-help leading-none flex items-center text-sky-500"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>'
25
+
26
+ const LEGACY_TICKS_FAILED =
27
+ '<span class="cursor-help leading-none flex items-center text-red-500"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span>'
28
+
29
+ const LEGACY_TICKS_READ_WITH_TITLE =
30
+ '<span class="cursor-help leading-none flex items-center text-sky-500" data-cv-tooltip="x"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>'
31
+
32
+ const LEGACY_COPIED_BADGE_MARKUP =
33
+ '<span class="absolute top-0 right-0 -translate-y-full bg-[#3b4a54] text-white text-[11px] px-1.5 py-0.5 rounded shadow-lg">Copiado!</span>'
34
+
35
+ const LEGACY_UNKNOWN_STATUS_MARKUP = LEGACY_TICKS_DELIVERED
36
+
37
+ function buildMessage(type: MessagePayload['type'], content: string): MessagePayload {
38
+ return {
39
+ id: 'msg-1',
40
+ type,
41
+ direction: 'inbound',
42
+ sender: 'customer',
43
+ timestamp: '2026-08-05T12:00:00.000Z',
44
+ content,
45
+ }
46
+ }
47
+
48
+ describe('MessageText mantém o markup da 0.4.2 por padrão', () => {
49
+ it('G1: texto comum é um div clicável sem role, tabindex nem teclado', () => {
50
+ const markup = renderToStaticMarkup(<MessageText message={buildMessage('text', '*oi*')} />)
51
+
52
+ expect(markup).toBe(LEGACY_TEXT_MARKUP)
53
+ expect(markup).not.toContain('role=')
54
+ expect(markup).not.toContain('tabindex')
55
+ })
56
+
57
+ it('G2: template continua sendo o parágrafo em itálico', () => {
58
+ const markup = renderToStaticMarkup(<MessageText message={buildMessage('template', 'Olá modelo')} />)
59
+
60
+ expect(markup).toBe(LEGACY_TEMPLATE_MARKUP)
61
+ })
62
+
63
+ it('G3: o selo de cópia é "Copiado!" com a classe Tailwind exata', () => {
64
+ expect(renderToStaticMarkup(<MessageTextCopiedBadge />)).toBe(LEGACY_COPIED_BADGE_MARKUP)
65
+ })
66
+ })
67
+
68
+ describe('StatusTicks mantém o markup da 0.4.2 por padrão', () => {
69
+ it('G4: sent', () => {
70
+ expect(renderToStaticMarkup(<StatusTicks status="sent" />)).toBe(LEGACY_TICKS_SENT)
71
+ })
72
+
73
+ it('G4: delivered', () => {
74
+ expect(renderToStaticMarkup(<StatusTicks status="delivered" />)).toBe(LEGACY_TICKS_DELIVERED)
75
+ })
76
+
77
+ it('G4: read', () => {
78
+ expect(renderToStaticMarkup(<StatusTicks status="read" />)).toBe(LEGACY_TICKS_READ)
79
+ })
80
+
81
+ it('G4: failed', () => {
82
+ expect(renderToStaticMarkup(<StatusTicks status="failed" />)).toBe(LEGACY_TICKS_FAILED)
83
+ })
84
+
85
+ it('G5: status desconhecido desenha dois tiques cinza', () => {
86
+ expect(renderToStaticMarkup(<StatusTicks status="pending" />)).toBe(LEGACY_UNKNOWN_STATUS_MARKUP)
87
+ expect(renderToStaticMarkup(<StatusTicks status="xyz" />)).toBe(LEGACY_UNKNOWN_STATUS_MARKUP)
88
+ })
89
+
90
+ it('G5: queued e bounced, que a 0.4.2 nunca recebia, também caem nos dois tiques cinza', () => {
91
+ expect(renderToStaticMarkup(<StatusTicks status="queued" />)).toBe(LEGACY_UNKNOWN_STATUS_MARKUP)
92
+ expect(renderToStaticMarkup(<StatusTicks status="bounced" />)).toBe(LEGACY_UNKNOWN_STATUS_MARKUP)
93
+ })
94
+
95
+ it('G6: title vira data-cv-tooltip e nunca aria-label', () => {
96
+ const markup = renderToStaticMarkup(<StatusTicks status="read" title="x" />)
97
+
98
+ expect(markup).toBe(LEGACY_TICKS_READ_WITH_TITLE)
99
+ expect(markup).toContain('data-cv-tooltip="x"')
100
+ expect(markup).not.toContain('aria-label')
101
+ })
102
+ })
103
+
104
+ describe('MessageBubble mantém o selo da 0.4.2', () => {
105
+ it('G7: mensagem enviada e lida contém o StatusTicks idêntico ao da 0.4.2', () => {
106
+ const message: MessagePayload = {
107
+ ...buildMessage('text', 'oi'),
108
+ direction: 'outbound',
109
+ sender: 'agent',
110
+ status: 'read',
111
+ }
112
+
113
+ expect(renderToStaticMarkup(<MessageBubble message={message} isMine />)).toContain(LEGACY_TICKS_READ)
114
+ })
115
+
116
+ it('G7: bounced no balão não vira falha: sem anel de erro e com dois tiques cinza', () => {
117
+ const message: MessagePayload = {
118
+ ...buildMessage('text', 'oi'),
119
+ direction: 'outbound',
120
+ sender: 'agent',
121
+ status: 'bounced',
122
+ }
123
+ const markup = renderToStaticMarkup(<MessageBubble message={message} isMine />)
124
+
125
+ expect(markup).toContain(LEGACY_TICKS_DELIVERED)
126
+ })
127
+ })