@adatechnology/conversations-ui 0.5.0 → 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 (50) hide show
  1. package/README.md +82 -20
  2. package/dist/{chunk-JXJCPN34.js → chunk-FAQLC3TX.js} +1 -1
  3. package/dist/{chunk-RJMSM3YA.js → chunk-O6457XHK.js} +17 -11
  4. package/dist/chunk-OVFCLRQQ.js +84 -0
  5. package/dist/index.d.ts +14 -4
  6. package/dist/index.js +3 -3
  7. package/dist/participant/index.d.ts +25 -1
  8. package/dist/participant/index.js +454 -194
  9. package/dist/preview/index.js +2 -2
  10. package/dist/styles.css +98 -0
  11. package/package.json +2 -2
  12. package/src/MessageBubble.test.tsx +2 -2
  13. package/src/MessageText.test.tsx +27 -10
  14. package/src/MessageText.tsx +25 -17
  15. package/src/MessageTextCopiedBadge.tsx +10 -2
  16. package/src/StatusTicks.test.tsx +27 -6
  17. package/src/StatusTicks.tsx +30 -15
  18. package/src/legacyChannels.golden.test.ts +89 -0
  19. package/src/legacyDefaults.golden.test.tsx +127 -0
  20. package/src/legacyStyles.fixture.css +1121 -0
  21. package/src/legacyStyles.golden.test.ts +121 -0
  22. package/src/messageTextCopyInteraction.test.ts +75 -0
  23. package/src/messageTextCopyInteraction.ts +37 -0
  24. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  25. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  26. package/src/participant/ParticipantConversations.tsx +13 -6
  27. package/src/participant/ParticipantConversationsScreen.tsx +18 -7
  28. package/src/participant/ParticipantInbox.test.tsx +99 -2
  29. package/src/participant/ParticipantInbox.tsx +27 -46
  30. package/src/participant/ParticipantInboxRow.tsx +75 -0
  31. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  32. package/src/participant/ParticipantMessageBubble.test.tsx +2 -0
  33. package/src/participant/ParticipantMessageBubble.tsx +3 -3
  34. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  35. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  36. package/src/participant/ParticipantThread.test.tsx +19 -2
  37. package/src/participant/ParticipantThread.tsx +25 -15
  38. package/src/participant/index.ts +1 -0
  39. package/src/participant/participant.types.ts +9 -0
  40. package/src/participant/participantChannels.test.ts +42 -0
  41. package/src/participant/participantChannels.ts +33 -0
  42. package/src/participant/participantGrouping.test.ts +101 -3
  43. package/src/participant/participantGrouping.ts +20 -7
  44. package/src/participant/participantLabels.ts +29 -0
  45. package/src/participant/participantProtocol.test.ts +104 -0
  46. package/src/participant/participantProtocol.ts +52 -0
  47. package/src/participant/participantProtocolCopy.test.ts +45 -0
  48. package/src/participant/participantProtocolCopy.ts +32 -0
  49. package/src/styles.css +19 -1
  50. package/dist/chunk-WSFGNR5R.js +0 -55
@@ -16,9 +16,9 @@ import {
16
16
  mediaTypeOf,
17
17
  simulatorPanelLabelsOf,
18
18
  toConversationSimulatorClient
19
- } from "../chunk-JXJCPN34.js";
19
+ } from "../chunk-FAQLC3TX.js";
20
20
  import "../chunk-MRVFFJEH.js";
21
- import "../chunk-RJMSM3YA.js";
21
+ import "../chunk-O6457XHK.js";
22
22
  import "../chunk-CBYEHPX6.js";
23
23
 
24
24
  // src/preview/previewStore.ts
package/dist/styles.css CHANGED
@@ -1527,6 +1527,46 @@
1527
1527
  text-transform: uppercase;
1528
1528
  color: var(--cv-p-i-accent);
1529
1529
  }
1530
+ .cv-p-inbox__icon {
1531
+ color: var(--cv-p-i-accent);
1532
+ font-size: 1rem;
1533
+ }
1534
+ .cv-p-inbox__icon svg {
1535
+ width: 1.25rem;
1536
+ height: 1.25rem;
1537
+ }
1538
+ .cv-p-row__tags {
1539
+ display: flex;
1540
+ flex-wrap: wrap;
1541
+ align-items: center;
1542
+ gap: 0.25rem 0.5rem;
1543
+ }
1544
+ .cv-p-channels {
1545
+ display: inline-flex;
1546
+ flex-wrap: wrap;
1547
+ gap: 0.25rem;
1548
+ margin: 0;
1549
+ padding: 0;
1550
+ list-style: none;
1551
+ }
1552
+ .cv-p-channel {
1553
+ display: inline-grid;
1554
+ place-items: center;
1555
+ width: 1.375rem;
1556
+ height: 1.375rem;
1557
+ border: 1px solid var(--cv-p-i-border);
1558
+ border-radius: var(--cv-p-i-radius);
1559
+ color: var(--cv-p-i-text-muted);
1560
+ }
1561
+ .cv-p-channel svg {
1562
+ display: block;
1563
+ }
1564
+ .cv-p-channel--whatsapp {
1565
+ color: var(--cv-p-channel-whatsapp, #20914f);
1566
+ }
1567
+ .cv-p-channel--app {
1568
+ color: var(--cv-p-channel-app, var(--cv-p-i-accent));
1569
+ }
1530
1570
  .cv-p-row__body {
1531
1571
  display: flex;
1532
1572
  flex-direction: column;
@@ -1578,6 +1618,12 @@
1578
1618
  padding: 0.5rem 1rem;
1579
1619
  border-bottom: 1px solid var(--cv-p-i-border);
1580
1620
  }
1621
+ .cv-p-thread__heading {
1622
+ flex: 1;
1623
+ min-width: 0;
1624
+ display: flex;
1625
+ flex-direction: column;
1626
+ }
1581
1627
  .cv-p-thread__title {
1582
1628
  flex: 1;
1583
1629
  margin: 0;
@@ -1823,7 +1869,59 @@
1823
1869
  font: inherit;
1824
1870
  cursor: pointer;
1825
1871
  }
1872
+ .cv-p-protocol {
1873
+ min-width: 0;
1874
+ overflow-wrap: anywhere;
1875
+ font:
1876
+ 0.7rem "SFMono-Regular",
1877
+ Consolas,
1878
+ monospace;
1879
+ letter-spacing: 0.04em;
1880
+ color: var(--cv-p-i-text-muted);
1881
+ }
1882
+ .cv-p-protocol--header {
1883
+ display: flex;
1884
+ align-items: center;
1885
+ gap: 0.5rem;
1886
+ }
1887
+ .cv-p-protocol__copy {
1888
+ min-height: var(--cv-p-i-touch);
1889
+ min-width: var(--cv-p-i-touch);
1890
+ padding: 0 0.75rem;
1891
+ background: transparent;
1892
+ border: 1px solid var(--cv-p-i-border);
1893
+ border-radius: var(--cv-p-i-radius);
1894
+ color: var(--cv-p-i-text);
1895
+ font: inherit;
1896
+ font-size: 0.75rem;
1897
+ cursor: pointer;
1898
+ }
1899
+ .cv-p-search {
1900
+ padding: 0.625rem 1rem;
1901
+ border-bottom: 1px solid var(--cv-p-i-border);
1902
+ }
1903
+ .cv-p-search__input {
1904
+ width: 100%;
1905
+ box-sizing: border-box;
1906
+ min-height: var(--cv-p-i-touch);
1907
+ padding: 0 0.75rem;
1908
+ font: inherit;
1909
+ font-size: 0.9rem;
1910
+ color: var(--cv-p-i-text);
1911
+ background: var(--cv-p-i-surface);
1912
+ border: 1px solid var(--cv-p-i-border);
1913
+ border-radius: var(--cv-p-i-radius);
1914
+ }
1915
+ .cv-p-search__input:focus-visible,
1916
+ .cv-p-protocol__copy:focus-visible {
1917
+ outline: 2px solid var(--cv-p-i-accent);
1918
+ outline-offset: 2px;
1919
+ }
1826
1920
  @media (prefers-reduced-motion: no-preference) {
1921
+ .cv-p-protocol__copy,
1922
+ .cv-p-search__input {
1923
+ transition: border-color 0.12s, background-color 0.12s;
1924
+ }
1827
1925
  .cv-p-row,
1828
1926
  .cv-p-button,
1829
1927
  .cv-p-chip {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,7 +35,7 @@
35
35
  "clsx": "^2.1.1",
36
36
  "lucide-react": "^1.21.0",
37
37
  "tailwind-merge": "^3.6.0",
38
- "@adatechnology/conversation-contracts": "0.4.0",
38
+ "@adatechnology/conversation-contracts": "0.5.0",
39
39
  "@adatechnology/meta-whatsapp-contracts": "0.6.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -56,13 +56,13 @@ describe('MessageBubble e o selo de lida por canal (D4, CA04)', () => {
56
56
  it('email não confirma leitura — o selo de lida não aparece mesmo com status "read"', () => {
57
57
  const markup = renderToStaticMarkup(<MessageBubble message={TEXT_MESSAGE} isMine channel="email" />)
58
58
 
59
- expect(markup).not.toContain('cv-status-ticks--read')
59
+ expect(markup).not.toContain('text-sky-500')
60
60
  })
61
61
 
62
62
  it('whatsapp confirma leitura — o selo de lida aparece normalmente', () => {
63
63
  const markup = renderToStaticMarkup(<MessageBubble message={TEXT_MESSAGE} isMine channel="whatsapp" />)
64
64
 
65
- expect(markup).toContain('cv-status-ticks--read')
65
+ expect(markup).toContain('text-sky-500')
66
66
  })
67
67
 
68
68
  it('sem channel, o padrão continua sendo whatsapp — comportamento de antes desta mudança', () => {
@@ -15,8 +15,8 @@ const TEXT_MESSAGE: MessagePayload = {
15
15
  }
16
16
 
17
17
  describe('MessageText', () => {
18
- it('usa a classe cv-message-text e nenhuma utilitária Tailwind', () => {
19
- const markup = renderToStaticMarkup(<MessageText message={TEXT_MESSAGE} />)
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
20
 
21
21
  expect(markup).toContain('cv-message-text')
22
22
  expect(markup).not.toContain('whitespace-pre-wrap')
@@ -25,8 +25,8 @@ describe('MessageText', () => {
25
25
  expect(markup).not.toContain('[&_')
26
26
  })
27
27
 
28
- it('o badge usa a classe cv-message-text__copied e nenhuma utilitária Tailwind', () => {
29
- const markup = renderToStaticMarkup(<MessageTextCopiedBadge />)
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
30
 
31
31
  expect(markup).toContain('cv-message-text__copied')
32
32
  expect(markup).not.toContain('absolute')
@@ -35,16 +35,33 @@ describe('MessageText', () => {
35
35
  expect(markup).not.toContain('-translate-y-full')
36
36
  })
37
37
 
38
- it('por padrão copia ao toque: role=button, tabindex e classe copiável (user-select só aí)', () => {
38
+ it('por padrão é o div da 0.4.2: classes Tailwind, sem role nem tabindex', () => {
39
39
  const markup = renderToStaticMarkup(<MessageText message={TEXT_MESSAGE} />)
40
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
+
41
51
  expect(markup).toContain('role="button"')
42
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
+
43
58
  expect(markup).toContain('cv-message-text--copyable')
44
59
  })
45
60
 
46
- it('com copyOnClick={false} não copia: sem role, sem tabindex e sem classe copiável', () => {
47
- const markup = renderToStaticMarkup(<MessageText message={TEXT_MESSAGE} copyOnClick={false} />)
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
+ )
48
65
 
49
66
  expect(markup).not.toContain('role="button"')
50
67
  expect(markup).not.toContain('tabindex')
@@ -52,8 +69,8 @@ describe('MessageText', () => {
52
69
  expect(markup).toContain('class="cv-message-text"')
53
70
  })
54
71
 
55
- it('o badge usa "Copied" por padrão e aceita outro rótulo', () => {
56
- expect(renderToStaticMarkup(<MessageTextCopiedBadge />)).toContain('Copied')
57
- expect(renderToStaticMarkup(<MessageTextCopiedBadge label="Copiado!" />)).toContain('Copiado!')
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')
58
75
  })
59
76
  })
@@ -1,18 +1,33 @@
1
- import { useCallback, useState, type KeyboardEvent } from 'react'
1
+ import { useCallback, useState } from 'react'
2
2
  import { CHAT_TEXT_SECONDARY_CLASS } from './theme'
3
3
  import type { MessagePayload } from './types'
4
4
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
5
- import { MessageTextCopiedBadge } from './MessageTextCopiedBadge'
5
+ import { resolveCopyInteraction } from './messageTextCopyInteraction'
6
+ import { MessageTextCopiedBadge, type MessageTextAppearance } from './MessageTextCopiedBadge'
6
7
 
7
8
  export interface MessageTextProps {
8
9
  message: MessagePayload
9
- /** Defaults to true (tap copies the text, as in 0.3.1); false disables copying and its button semantics. */
10
+ /** Defaults to true (tap copies the text, as in 0.4.2); false disables copying. */
10
11
  copyOnClick?: boolean
11
- /** Text of the badge shown after copying; absent means the package default ("Copied"). */
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. */
12
15
  copiedLabel?: string
16
+ /** 'tailwind' (default) keeps the 0.4.2 utility classes; 'stylesheet' uses the .cv-* rules from styles.css. */
17
+ appearance?: MessageTextAppearance
13
18
  }
14
19
 
15
- export function MessageText({ message, copyOnClick = true, copiedLabel }: 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) {
16
31
  const [copied, setCopied] = useState(false)
17
32
 
18
33
  const handleCopy = useCallback(async () => {
@@ -22,28 +37,21 @@ export function MessageText({ message, copyOnClick = true, copiedLabel }: Messag
22
37
  setCopied(true)
23
38
  setTimeout(() => setCopied(false), 2000)
24
39
  } catch {
25
- // Clipboard not available
26
40
  }
27
41
  }, [copyOnClick, message.content])
28
42
 
29
- const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
30
- if (event.key !== 'Enter' && event.key !== ' ') return
31
- event.preventDefault()
32
- void handleCopy()
33
- }
34
-
35
43
  if (message.type === 'template')
36
44
  return <p className={`text-sm italic ${CHAT_TEXT_SECONDARY_CLASS}`}>{message.content ?? 'Template message'}</p>
37
45
 
46
+ const interactionProps = resolveCopyInteraction({ copyOnClick, accessibleCopy, onCopy: handleCopy })
47
+
38
48
  return (
39
49
  <div
40
- className={copyOnClick ? 'cv-message-text cv-message-text--copyable' : 'cv-message-text'}
41
- {...(copyOnClick
42
- ? { role: 'button', tabIndex: 0, onClick: handleCopy, onKeyDown: handleKeyDown }
43
- : {})}
50
+ className={resolveClassName(appearance, copyOnClick)}
51
+ {...interactionProps}
44
52
  >
45
53
  <div>{parseWhatsAppFormatting(message.content ?? '')}</div>
46
- {copied && <MessageTextCopiedBadge label={copiedLabel} />}
54
+ {copied && <MessageTextCopiedBadge label={copiedLabel} appearance={appearance} />}
47
55
  </div>
48
56
  )
49
57
  }
@@ -1,7 +1,15 @@
1
+ export type MessageTextAppearance = 'tailwind' | 'stylesheet'
2
+
1
3
  export interface MessageTextCopiedBadgeProps {
2
4
  label?: string
5
+ /** 'tailwind' (default) keeps the 0.4.2 utility classes; 'stylesheet' uses the .cv-* rules from styles.css. */
6
+ appearance?: MessageTextAppearance
3
7
  }
4
8
 
5
- export function MessageTextCopiedBadge({ label = 'Copied' }: MessageTextCopiedBadgeProps) {
6
- return <span className="cv-message-text__copied">{label}</span>
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>
7
15
  }
@@ -6,22 +6,43 @@ import { StatusTicks } from './StatusTicks'
6
6
  const TAILWIND_UTILITIES = ['text-sky-500', 'text-red-500', 'text-black/40', 'flex', 'cursor-help', 'leading-none']
7
7
 
8
8
  describe('StatusTicks', () => {
9
- it('renderiza o estado queued com relógio e rótulo acessível', () => {
10
- const markup = renderToStaticMarkup(<StatusTicks status="queued" />)
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" />)
11
17
 
12
18
  expect(markup).toContain('cv-status-ticks--queued')
13
19
  expect(markup).toContain('aria-label="Queued"')
14
20
  })
15
21
 
16
- it('trata bounced como failed', () => {
17
- const markup = renderToStaticMarkup(<StatusTicks status="bounced" />)
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" />)
18
30
 
19
31
  expect(markup).toContain('cv-status-ticks--failed')
20
32
  expect(markup).not.toContain('cv-status-ticks--bounced')
21
33
  })
22
34
 
23
- it.each(['sent', 'delivered', 'read', 'failed', 'queued'])('usa só classes cv-* em %s', (status) => {
24
- const markup = renderToStaticMarkup(<StatusTicks status={status} />)
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" />)
25
46
 
26
47
  expect(markup).toContain(`cv-status-ticks--${status}`)
27
48
  for (const utility of TAILWIND_UTILITIES) expect(markup).not.toContain(utility)
@@ -3,9 +3,20 @@ import { AlertTriangle, Clock } from 'lucide-react'
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_MODIFIER: Record<string, string> = {
12
+ const TAILWIND_COLOR_CLASS: Record<string, string> = {
13
+ sent: 'text-black/40 dark:text-white/40',
14
+ delivered: 'text-black/40 dark:text-white/40',
15
+ read: 'text-sky-500',
16
+ failed: 'text-red-500',
17
+ }
18
+
19
+ const STYLESHEET_MODIFIER: Record<string, string> = {
9
20
  queued: 'queued',
10
21
  sent: 'sent',
11
22
  delivered: 'delivered',
@@ -14,8 +25,6 @@ const STATUS_MODIFIER: Record<string, string> = {
14
25
  bounced: 'failed',
15
26
  }
16
27
 
17
- const QUEUED_LABEL = 'Queued'
18
-
19
28
  function Ticks({ double }: { double: boolean }) {
20
29
  return (
21
30
  <svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -30,17 +39,11 @@ function Ticks({ double }: { double: boolean }) {
30
39
  )
31
40
  }
32
41
 
33
- // Paridade com financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx —
34
- // mesmo path de SVG, mesma cor por status (read → sky-500, failed → red-500 com AlertTriangle).
35
- function StatusIcon({ modifier }: { modifier: string }) {
36
- if (modifier === 'failed') return <AlertTriangle size={11} />
37
- if (modifier === 'queued') return <Clock size={11} />
38
- return <Ticks double={modifier !== 'sent'} />
39
- }
40
-
41
- export function StatusTicks({ status, title }: StatusTicksProps) {
42
- const modifier = STATUS_MODIFIER[status] ?? 'sent'
43
- const ariaLabel = modifier === 'queued' ? QUEUED_LABEL : undefined
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'} />
44
47
 
45
48
  return (
46
49
  <span
@@ -48,7 +51,19 @@ export function StatusTicks({ status, title }: StatusTicksProps) {
48
51
  data-cv-tooltip={title ?? ariaLabel}
49
52
  aria-label={ariaLabel}
50
53
  >
51
- <StatusIcon modifier={modifier} />
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
63
+
64
+ return (
65
+ <span className={`cursor-help leading-none flex items-center ${colorClass}`} data-cv-tooltip={title}>
66
+ {status === 'failed' ? <AlertTriangle size={11} /> : <Ticks double={status !== 'sent'} />}
52
67
  </span>
53
68
  )
54
69
  }
@@ -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
+ })