@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-OIDAIVCH.js +1985 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +729 -136
  6. package/dist/index.js +1418 -1104
  7. package/dist/preview/index.d.ts +373 -0
  8. package/dist/preview/index.js +1247 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-O7kMP1Yn.d.ts +416 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/Avatar.tsx +30 -4
  15. package/src/ChannelIcon.tsx +87 -0
  16. package/src/ConversationContextPanel.tsx +127 -0
  17. package/src/ConversationDocumentsPanel.tsx +425 -0
  18. package/src/ConversationHeader.tsx +257 -0
  19. package/src/ConversationListItem.tsx +54 -7
  20. package/src/ConversationLocalesProvider.tsx +16 -0
  21. package/src/ConversationRow.tsx +137 -0
  22. package/src/DateDivider.tsx +16 -3
  23. package/src/DocumentsLibrary.tsx +322 -0
  24. package/src/EmojiPicker.tsx +69 -55
  25. package/src/FileIcon.test.ts +83 -0
  26. package/src/FileIcon.tsx +88 -11
  27. package/src/InteractiveMessage.test.tsx +41 -0
  28. package/src/InteractiveMessage.tsx +143 -0
  29. package/src/Lightbox.tsx +18 -3
  30. package/src/MediaRenderer.tsx +64 -21
  31. package/src/MessageBubble.tsx +54 -5
  32. package/src/MessageComposer.test.tsx +35 -0
  33. package/src/MessageComposer.tsx +146 -19
  34. package/src/RichMessageComposer.test.tsx +83 -0
  35. package/src/RichMessageComposer.tsx +380 -0
  36. package/src/Wallpaper.test.tsx +21 -0
  37. package/src/Wallpaper.tsx +54 -6
  38. package/src/WhatsAppMessageEditor.tsx +28 -4
  39. package/src/WindowExpiredNotice.tsx +57 -0
  40. package/src/audioRecorderFormat.test.ts +67 -0
  41. package/src/conversationChannel.test.ts +53 -0
  42. package/src/conversationChannel.ts +146 -0
  43. package/src/conversationTranscript.test.ts +65 -0
  44. package/src/conversationTranscript.ts +64 -0
  45. package/src/conversationWindow.test.ts +90 -0
  46. package/src/conversationWindow.ts +78 -0
  47. package/src/documentTypeLabel.test.ts +57 -0
  48. package/src/emojiCatalog.test.ts +35 -0
  49. package/src/emojiCatalog.ts +189 -0
  50. package/src/flows/FlowGroupHeader.tsx +12 -2
  51. package/src/flows/FlowMapCanvas.tsx +17 -14
  52. package/src/flows/FlowMapNode.tsx +3 -1
  53. package/src/flows/FlowNodeCard.tsx +22 -4
  54. package/src/flows/FlowNodePanel.tsx +132 -35
  55. package/src/flows/FlowPalette.tsx +6 -2
  56. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  57. package/src/flows/flowGraph.ts +5 -5
  58. package/src/flows/labels.ts +5 -0
  59. package/src/hooks/useConversationActions.ts +56 -0
  60. package/src/hooks/useConversationDocuments.ts +15 -9
  61. package/src/hooks/useConversationList.ts +15 -9
  62. package/src/hooks/useConversationMessages.ts +2 -2
  63. package/src/index.ts +129 -16
  64. package/src/lib/cn.test.ts +29 -0
  65. package/src/lib/cn.ts +15 -0
  66. package/src/lib/createMediaUrlResolver.ts +33 -0
  67. package/src/lib/paginated.test.ts +33 -0
  68. package/src/lib/paginated.ts +26 -0
  69. package/src/lib/phone.ts +34 -0
  70. package/src/preview/ConversationPreview.tsx +325 -0
  71. package/src/preview/MediaTypesPreview.tsx +87 -0
  72. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  73. package/src/preview/createMockConversationsApi.ts +271 -0
  74. package/src/preview/createMockSSEProvider.ts +40 -0
  75. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  76. package/src/preview/createPreviewBridgeClient.ts +88 -0
  77. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  78. package/src/preview/createPreviewWebhookClient.ts +131 -0
  79. package/src/preview/index.ts +61 -0
  80. package/src/preview/mediaTypeOf.test.ts +15 -0
  81. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  82. package/src/preview/mockEventSource.ts +53 -0
  83. package/src/preview/preview.test.ts +177 -0
  84. package/src/preview/previewFileSamples.test.ts +151 -0
  85. package/src/preview/previewFileSamples.ts +74 -0
  86. package/src/preview/previewFixtures.ts +440 -0
  87. package/src/preview/previewMediaSource.test.ts +62 -0
  88. package/src/preview/previewMediaSource.ts +91 -0
  89. package/src/preview/previewStore.ts +193 -0
  90. package/src/preview/startPreviewScript.ts +60 -0
  91. package/src/providers/types.ts +163 -11
  92. package/src/quickReply.test.ts +58 -0
  93. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  94. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  95. package/src/styles.css +136 -0
  96. package/src/types.ts +46 -1
  97. package/src/useDarkMode.ts +26 -0
  98. package/src/useIsNarrow.ts +29 -0
  99. package/src/useWaitingNotifications.ts +74 -29
@@ -1,39 +1,27 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useMemo } from 'react'
2
+ import { EMOJI_CATEGORIES, searchEmojis, type EmojiEntry } from './emojiCatalog'
3
+
4
+ export interface EmojiPickerLabels {
5
+ search: string
6
+ noResults: string
7
+ }
8
+
9
+ export const DEFAULT_EMOJI_PICKER_LABELS: EmojiPickerLabels = {
10
+ search: 'Buscar emoji',
11
+ noResults: 'Nenhum emoji encontrado',
12
+ }
2
13
 
3
14
  export interface EmojiPickerProps {
4
15
  onSelect: (emoji: string) => void
16
+ labels?: Partial<EmojiPickerLabels>
5
17
  className?: string
6
18
  }
7
19
 
8
- const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
9
- {
10
- name: 'Smileys',
11
- emojis: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘', '😗', '😋', '😛', '😜', '🤪'],
12
- },
13
- {
14
- name: 'Gestures',
15
- emojis: ['👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '🤝', '🙏', '✍️', '💅', '🤳'],
16
- },
17
- {
18
- name: 'Hearts',
19
- emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '♥️'],
20
- },
21
- {
22
- name: 'Food',
23
- emojis: ['🍔', '🍟', '🍕', '🌭', '🍿', '🧂', '🥓', '🥚', '🍳', '🧇', '🥞', '🧈', '🍞', '🥐', '🥨', '🥯', '🥖', '🧀', '🥗', '🥙'],
24
- },
25
- {
26
- name: 'Drinks',
27
- emojis: ['☕', '🍵', '🍶', '🍾', '🍷', '🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🧋', '🧃', '🧉', '🧊', '🥢', '🍽️', '🍴', '🥄'],
28
- },
29
- {
30
- name: 'Objects',
31
- emojis: ['🎁', '🎂', '🎈', '🎉', '🎊', '🎀', '📱', '💻', '⌚', '📷', '🔑', '💰', '💳', '📝', '📌', '📍', '✂️', '🔍', '💡', '🔔'],
32
- },
33
- ]
34
-
35
- export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
20
+ export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerProps) => {
21
+ const searchLabel = labels?.search ?? DEFAULT_EMOJI_PICKER_LABELS.search
22
+ const noResultsLabel = labels?.noResults ?? DEFAULT_EMOJI_PICKER_LABELS.noResults
36
23
  const [activeCategory, setActiveCategory] = useState(0)
24
+ const [query, setQuery] = useState('')
37
25
 
38
26
  const handleSelect = useCallback(
39
27
  (emoji: string) => {
@@ -42,36 +30,62 @@ export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
42
30
  [onSelect],
43
31
  )
44
32
 
33
+ // Buscando, as abas de categoria saem do caminho: o resultado atravessa todas elas, e manter uma
34
+ // aba destacada sugeriria que a busca está restrita àquela categoria.
35
+ const isSearching = query.trim().length > 0
36
+ const visibleEntries: readonly EmojiEntry[] = useMemo(
37
+ () => (isSearching ? searchEmojis(query) : EMOJI_CATEGORIES[activeCategory].entries),
38
+ [isSearching, query, activeCategory],
39
+ )
40
+
45
41
  return (
46
42
  <div className={`bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden ${className}`}>
47
- <div className="flex border-b border-gray-200 overflow-x-auto">
48
- {EMOJI_CATEGORIES.map((category, index) => (
49
- <button
50
- key={category.name}
51
- onClick={() => setActiveCategory(index)}
52
- className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
53
- activeCategory === index
54
- ? 'border-blue-500 text-blue-600'
55
- : 'border-transparent text-gray-500 hover:text-gray-700'
56
- }`}
57
- >
58
- {category.emojis[0]} {category.name}
59
- </button>
60
- ))}
43
+ <div className="p-2 border-b border-gray-200">
44
+ <input
45
+ type="search"
46
+ value={query}
47
+ onChange={(event) => setQuery(event.target.value)}
48
+ placeholder={searchLabel}
49
+ aria-label={searchLabel}
50
+ className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm outline-none focus:border-blue-500"
51
+ />
61
52
  </div>
62
53
 
63
- <div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
64
- {EMOJI_CATEGORIES[activeCategory].emojis.map((emoji) => (
65
- <button
66
- key={emoji}
67
- onClick={() => handleSelect(emoji)}
68
- className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
69
- aria-label={emoji}
70
- >
71
- {emoji}
72
- </button>
73
- ))}
74
- </div>
54
+ {isSearching ? null : (
55
+ <div className="flex border-b border-gray-200 overflow-x-auto">
56
+ {EMOJI_CATEGORIES.map((category, index) => (
57
+ <button
58
+ key={category.name}
59
+ onClick={() => setActiveCategory(index)}
60
+ className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
61
+ activeCategory === index
62
+ ? 'border-blue-500 text-blue-600'
63
+ : 'border-transparent text-gray-500 hover:text-gray-700'
64
+ }`}
65
+ >
66
+ {category.entries[0].emoji} {category.name}
67
+ </button>
68
+ ))}
69
+ </div>
70
+ )}
71
+
72
+ {visibleEntries.length === 0 ? (
73
+ <p className="px-3 py-6 text-center text-sm text-gray-500">{noResultsLabel}</p>
74
+ ) : (
75
+ <div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
76
+ {visibleEntries.map((entry) => (
77
+ <button
78
+ key={entry.emoji}
79
+ onClick={() => handleSelect(entry.emoji)}
80
+ className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
81
+ aria-label={entry.emoji}
82
+ title={entry.keywords[0]}
83
+ >
84
+ {entry.emoji}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ )}
75
89
  </div>
76
90
  )
77
91
  }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { resolveFileIconExtension } from './FileIcon'
4
+
5
+ describe('resolveFileIconExtension', () => {
6
+ // Regressão: o painel de documentos passava só o mimeType, e o do Office é longo
7
+ // (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`), então docx, doc,
8
+ // xlsx e xls apareciam todos com o ícone genérico cinza.
9
+ const OFFICE_MIME_TYPES = [
10
+ {
11
+ filename: 'contrato.docx',
12
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
13
+ expected: 'docx',
14
+ },
15
+ { filename: 'procuracao.doc', mimeType: 'application/msword', expected: 'doc' },
16
+ {
17
+ filename: 'pedido.xlsx',
18
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
19
+ expected: 'xlsx',
20
+ },
21
+ { filename: 'tabela.xls', mimeType: 'application/vnd.ms-excel', expected: 'xls' },
22
+ ]
23
+
24
+ for (const testCase of OFFICE_MIME_TYPES) {
25
+ it(`resolve ${testCase.expected} pelo nome, já que o mimeType do Office não bate`, () => {
26
+ expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
27
+ })
28
+ }
29
+
30
+ it('resolve pelo mimeType quando o nome não tem extensão conhecida', () => {
31
+ expect(resolveFileIconExtension(undefined, 'application/pdf')).toBe('pdf')
32
+ expect(resolveFileIconExtension('recibo', 'application/zip')).toBe('zip')
33
+ })
34
+
35
+ it('devolve algo fora do mapa para tipo desconhecido, caindo no ícone genérico', () => {
36
+ expect(resolveFileIconExtension('backup', 'application/octet-stream')).toBe('octet-stream')
37
+ })
38
+
39
+ // Imagem, vídeo e áudio entram na biblioteca junto dos documentos — o backend linka as cinco
40
+ // espécies de mídia (image/audio/video/document/sticker), não só `document`.
41
+ const MEDIA_TYPES = [
42
+ { filename: 'foto.jpg', mimeType: 'image/jpeg', expected: 'jpg' },
43
+ { filename: 'prateleira.png', mimeType: 'image/png', expected: 'png' },
44
+ { filename: 'video-do-produto.mp4', mimeType: 'video/mp4', expected: 'mp4' },
45
+ { filename: 'antigo.3gp', mimeType: 'video/3gp', expected: '3gp' },
46
+ { filename: 'musica.mp3', mimeType: 'audio/mpeg', expected: 'mp3' },
47
+ { filename: 'recado.m4a', mimeType: 'audio/mp4', expected: 'm4a' },
48
+ { filename: 'lista-compras.txt', mimeType: 'text/plain', expected: 'txt' },
49
+ { filename: 'planilha.csv', mimeType: 'text/csv', expected: 'csv' },
50
+ {
51
+ filename: 'apresentacao.pptx',
52
+ mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
53
+ expected: 'pptx',
54
+ },
55
+ ]
56
+
57
+ for (const testCase of MEDIA_TYPES) {
58
+ it(`resolve ${testCase.expected}`, () => {
59
+ expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
60
+ })
61
+ }
62
+
63
+ // Áudio e sticker chegam da Meta sem nome de arquivo: o backend salva o id da mídia como rótulo,
64
+ // então a única pista de tipo é o mimeType.
65
+ it('resolve pela família quando o nome é o id da mídia, sem extensão', () => {
66
+ expect(resolveFileIconExtension('wamid-abc123', 'image/webp')).toBe('image')
67
+ expect(resolveFileIconExtension('wamid-abc123', 'video/mp4')).toBe('video')
68
+ expect(resolveFileIconExtension('wamid-abc123', 'audio/aac')).toBe('audio')
69
+ })
70
+
71
+ // `audio/ogg; codecs=opus` é o formato do áudio de WhatsApp. Com o parâmetro colado, o subtipo
72
+ // viria "ogg; codecs=opus" e o áudio cairia no ícone genérico.
73
+ it('descarta parâmetro do mimeType', () => {
74
+ expect(resolveFileIconExtension('wamid-audio', 'audio/ogg; codecs=opus')).toBe('audio')
75
+ })
76
+
77
+ // Pelo subtipo, `audio/mp4` casaria a chave `mp4` — que é vídeo. A família vem primeiro
78
+ // justamente para um m4a não aparecer com ícone de filme.
79
+ it('não confunde audio/mp4 com vídeo', () => {
80
+ expect(resolveFileIconExtension(undefined, 'audio/mp4')).toBe('audio')
81
+ expect(resolveFileIconExtension(undefined, 'video/mp4')).toBe('video')
82
+ })
83
+ })
package/src/FileIcon.tsx CHANGED
@@ -1,4 +1,15 @@
1
- import { FileArchive, FileSpreadsheet, FileText, File as FileGeneric } from 'lucide-react'
1
+ import {
2
+ FileArchive,
3
+ FileAudio,
4
+ FileImage,
5
+ FileSpreadsheet,
6
+ FileText,
7
+ FileVideo,
8
+ File as FileGeneric,
9
+ Presentation,
10
+ } from 'lucide-react'
11
+
12
+ import { cn } from './lib/cn'
2
13
 
3
14
  export interface FileIconProps {
4
15
  filename?: string
@@ -7,27 +18,93 @@ export interface FileIconProps {
7
18
  className?: string
8
19
  }
9
20
 
10
- const EXTENSION_STYLE: Record<string, { Icon: typeof FileText; colorClass: string }> = {
21
+ type IconStyle = { Icon: typeof FileText; colorClass: string }
22
+
23
+ // Um estilo por família, reaproveitado por todas as extensões dela: assim o ícone de um `.jpg`
24
+ // resolvido pelo nome é o mesmo de um `image/jpeg` resolvido pelo mimeType. Com cor por extensão, a
25
+ // mesma foto trocaria de cor conforme o dado que chegou junto.
26
+ const IMAGE_STYLE: IconStyle = { Icon: FileImage, colorClass: 'text-violet-500' }
27
+ const VIDEO_STYLE: IconStyle = { Icon: FileVideo, colorClass: 'text-fuchsia-500' }
28
+ const AUDIO_STYLE: IconStyle = { Icon: FileAudio, colorClass: 'text-amber-500' }
29
+ const SHEET_STYLE: IconStyle = { Icon: FileSpreadsheet, colorClass: 'text-green-600' }
30
+ const WORD_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-blue-500' }
31
+ const SLIDES_STYLE: IconStyle = { Icon: Presentation, colorClass: 'text-orange-600' }
32
+ const TEXT_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-gray-500' }
33
+
34
+ const EXTENSION_STYLE: Record<string, IconStyle> = {
11
35
  pdf: { Icon: FileText, colorClass: 'text-red-500' },
12
- doc: { Icon: FileText, colorClass: 'text-blue-500' },
13
- docx: { Icon: FileText, colorClass: 'text-blue-500' },
14
- xls: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
15
- xlsx: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
36
+ doc: WORD_STYLE,
37
+ docx: WORD_STYLE,
38
+ xls: SHEET_STYLE,
39
+ xlsx: SHEET_STYLE,
40
+ csv: SHEET_STYLE,
41
+ ppt: SLIDES_STYLE,
42
+ pptx: SLIDES_STYLE,
16
43
  zip: { Icon: FileArchive, colorClass: 'text-orange-500' },
44
+ txt: TEXT_STYLE,
45
+ plain: TEXT_STYLE,
46
+
47
+ image: IMAGE_STYLE,
48
+ jpg: IMAGE_STYLE,
49
+ jpeg: IMAGE_STYLE,
50
+ png: IMAGE_STYLE,
51
+ webp: IMAGE_STYLE,
52
+ gif: IMAGE_STYLE,
53
+ heic: IMAGE_STYLE,
54
+
55
+ video: VIDEO_STYLE,
56
+ mp4: VIDEO_STYLE,
57
+ '3gp': VIDEO_STYLE,
58
+ '3gpp': VIDEO_STYLE,
59
+ mov: VIDEO_STYLE,
60
+ webm: VIDEO_STYLE,
61
+
62
+ audio: AUDIO_STYLE,
63
+ mp3: AUDIO_STYLE,
64
+ mpeg: AUDIO_STYLE,
65
+ ogg: AUDIO_STYLE,
66
+ oga: AUDIO_STYLE,
67
+ opus: AUDIO_STYLE,
68
+ aac: AUDIO_STYLE,
69
+ amr: AUDIO_STYLE,
70
+ m4a: AUDIO_STYLE,
71
+ wav: AUDIO_STYLE,
17
72
  }
18
73
 
19
- function getExtension(filename?: string, mimeType?: string): string {
74
+ const MEDIA_FAMILIES = new Set(['image', 'video', 'audio'])
75
+
76
+ /**
77
+ * A chave de estilo do arquivo, na ordem em que cada dado é confiável.
78
+ *
79
+ * 1. **extensão do nome** — o mimeType do Office é longo (`…wordprocessingml.document`) e nunca
80
+ * casaria, então quem olhasse só o mimeType perderia o ícone de Word e de Excel;
81
+ * 2. **família do mimeType** (`image/`, `video/`, `audio/`) — tem de vir ANTES do subtipo por causa
82
+ * de `audio/mp4`: pelo subtipo, um áudio m4a ganharia o ícone de vídeo;
83
+ * 3. **subtipo** — cobre `application/pdf` e `application/zip`, que chegam sem nome de arquivo.
84
+ *
85
+ * Áudio de WhatsApp chega como `audio/ogg; codecs=opus`; o parâmetro depois do `;` é descartado,
86
+ * senão o subtipo viria `ogg; codecs=opus` e não casaria nada.
87
+ *
88
+ * Exportada para teste: é a regra que já regrediu uma vez no painel de documentos.
89
+ */
90
+ export function resolveFileIconExtension(filename?: string, mimeType?: string): string {
20
91
  const fromFilename = filename?.split('.').pop()?.toLowerCase()
21
92
  if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
22
- return mimeType?.split('/')[1]?.toLowerCase() ?? ''
93
+
94
+ const [family, subtype] = (mimeType ?? '').split(';')[0]!.toLowerCase().split('/')
95
+ if (family && MEDIA_FAMILIES.has(family)) return family
96
+
97
+ return subtype ?? ''
23
98
  }
24
99
 
25
100
  // Melhoria sobre a paridade da bolha de documento (T6.7 usava um único ícone genérico
26
101
  // para qualquer tipo de arquivo) — ícone e cor variam por extensão/mimeType.
27
- export function FileIcon({ filename, mimeType, size = 20, className = '' }: FileIconProps) {
28
- const extension = getExtension(filename, mimeType)
102
+ export function FileIcon({ filename, mimeType, size = 20, className }: FileIconProps) {
103
+ const extension = resolveFileIconExtension(filename, mimeType)
29
104
  const style = EXTENSION_STYLE[extension] ?? { Icon: FileGeneric, colorClass: 'text-gray-500' }
30
105
  const { Icon, colorClass } = style
31
106
 
32
- return <Icon size={size} className={`${colorClass} ${className}`.trim()} />
107
+ // `cn` em vez de concatenar: a cor por extensão é a base, e produto que passa `text-*` precisa
108
+ // ganhar dela — concatenado, quem vence é a ordem no CSS gerado, não a intenção de quem chamou.
109
+ return <Icon size={size} className={cn(colorClass, className)} />
33
110
  }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { InteractiveMessage } from './InteractiveMessage'
5
+ import type { InteractivePayload } from './types'
6
+
7
+ function render(payload: InteractivePayload): string {
8
+ return renderToStaticMarkup(<InteractiveMessage payload={payload} />)
9
+ }
10
+
11
+ describe('InteractiveMessage', () => {
12
+ it('formata negrito no corpo, como o aparelho faz', () => {
13
+ const markup = render({ type: 'button', body: { text: 'Qual o *prazo desejado*?' } })
14
+
15
+ expect(markup).toContain('<strong>prazo desejado</strong>')
16
+ expect(markup).not.toContain('*prazo desejado*')
17
+ })
18
+
19
+ it('formata cabeçalho e rodapé também', () => {
20
+ const markup = render({
21
+ type: 'list',
22
+ header: { text: '*Menu*' },
23
+ body: { text: 'corpo' },
24
+ footer: { text: '_Selecione_' },
25
+ })
26
+
27
+ expect(markup).toContain('<strong>Menu</strong>')
28
+ expect(markup).toContain('<em>Selecione</em>')
29
+ })
30
+
31
+ it('deixa título de opção literal, porque o WhatsApp não formata ali', () => {
32
+ const markup = render({
33
+ type: 'button',
34
+ body: { text: 'corpo' },
35
+ action: { buttons: [{ reply: { id: 'sim', title: '*Sim*' } }] },
36
+ })
37
+
38
+ expect(markup).toContain('*Sim*')
39
+ expect(markup).not.toContain('<strong>Sim</strong>')
40
+ })
41
+ })
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Menu interativo do WhatsApp — botões de resposta rápida e lista de opções.
3
+ *
4
+ * A mensagem é gravada com o payload cru que foi enviado à Meta, então a UI apenas o desenha; não
5
+ * há segunda fonte de verdade sobre quais opções existiam naquele momento. Sem isto, mensagem
6
+ * `interactive` aparecia como texto solto e o menu simplesmente sumia da conversa — inclusive no
7
+ * simulador, onde o operador precisa ver exatamente o que o cliente vê.
8
+ *
9
+ * `onSelect` é opcional de propósito: no histórico da inbox as opções são leitura (o operador não
10
+ * responde no lugar do cliente); no simulador elas são clicáveis.
11
+ */
12
+
13
+ import { useState } from 'react'
14
+ import type { InteractiveOption, InteractivePayload, InteractiveSelection } from './types'
15
+ import { cn } from './lib/cn'
16
+ import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
17
+
18
+ /** `parseWhatsAppFormatting` emite `<strong>`/`<em>`/`<del>`, que o reset do Tailwind zera. */
19
+ const FORMATTING_CLASSES = '[&_strong]:font-bold [&_em]:italic [&_del]:line-through'
20
+
21
+ export interface InteractiveMessageLabels {
22
+ /** Fallback do rótulo do botão que abre a lista, quando o payload não traz um. */
23
+ openList: string
24
+ }
25
+
26
+ export const DEFAULT_INTERACTIVE_MESSAGE_LABELS: InteractiveMessageLabels = {
27
+ openList: 'Ver opções',
28
+ }
29
+
30
+ export interface InteractiveMessageProps {
31
+ payload: InteractivePayload
32
+ onSelect?: (selection: InteractiveSelection) => void
33
+ labels?: Partial<InteractiveMessageLabels>
34
+ className?: string
35
+ }
36
+
37
+ function collectRows(payload: InteractivePayload): InteractiveOption[] {
38
+ return (payload.action?.sections ?? []).flatMap((section) => section.rows ?? [])
39
+ }
40
+
41
+ function collectButtons(payload: InteractivePayload): InteractiveOption[] {
42
+ return (payload.action?.buttons ?? [])
43
+ .map((button) => button.reply)
44
+ .filter((reply): reply is InteractiveOption => reply !== undefined)
45
+ }
46
+
47
+ export function InteractiveMessage({ payload, onSelect, labels, className }: InteractiveMessageProps) {
48
+ const openListLabel = labels?.openList ?? DEFAULT_INTERACTIVE_MESSAGE_LABELS.openList
49
+ const [isListOpen, setIsListOpen] = useState(false)
50
+
51
+ const buttons = collectButtons(payload)
52
+ const sections = payload.action?.sections ?? []
53
+ const hasRows = collectRows(payload).length > 0
54
+ const isInteractable = onSelect !== undefined
55
+
56
+ return (
57
+ <div className={cn('flex flex-col gap-1', className)}>
58
+ {/* Cabeçalho, corpo e rodapé passam pela formatação do WhatsApp — o aparelho renderiza
59
+ `*negrito*` neles como em qualquer texto. Título e descrição de opção NÃO: ali o WhatsApp
60
+ mostra os asteriscos literais, e formatá-los aqui deixaria o simulador mais bonito que o
61
+ aparelho, que é o tipo de divergência que só aparece com o cliente na frente. */}
62
+ {payload.header?.text ? (
63
+ <p className={cn('text-sm font-semibold text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}>
64
+ {parseWhatsAppFormatting(payload.header.text)}
65
+ </p>
66
+ ) : null}
67
+
68
+ {payload.body?.text ? (
69
+ <p
70
+ className={cn(
71
+ 'whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100',
72
+ FORMATTING_CLASSES,
73
+ )}
74
+ >
75
+ {parseWhatsAppFormatting(payload.body.text)}
76
+ </p>
77
+ ) : null}
78
+
79
+ {payload.footer?.text ? (
80
+ <p className={cn('text-xs text-gray-500 dark:text-gray-400', FORMATTING_CLASSES)}>
81
+ {parseWhatsAppFormatting(payload.footer.text)}
82
+ </p>
83
+ ) : null}
84
+
85
+ {buttons.length > 0 ? (
86
+ <div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
87
+ {buttons.map((button) => (
88
+ <button
89
+ key={button.id}
90
+ type="button"
91
+ disabled={!isInteractable}
92
+ onClick={() => onSelect?.({ kind: 'button', option: button })}
93
+ className="rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors enabled:hover:bg-teal-50 disabled:cursor-default dark:text-teal-300 dark:enabled:hover:bg-teal-900/30"
94
+ >
95
+ {button.title}
96
+ </button>
97
+ ))}
98
+ </div>
99
+ ) : null}
100
+
101
+ {hasRows ? (
102
+ <div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
103
+ <button
104
+ type="button"
105
+ onClick={() => setIsListOpen((open) => !open)}
106
+ aria-expanded={isListOpen}
107
+ className="w-full rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors hover:bg-teal-50 dark:text-teal-300 dark:hover:bg-teal-900/30"
108
+ >
109
+ ☰ {payload.action?.button ?? openListLabel}
110
+ </button>
111
+
112
+ {isListOpen ? (
113
+ <div className="mt-1 flex flex-col gap-1">
114
+ {sections.map((section, sectionIndex) => (
115
+ <div key={section.title ?? sectionIndex} className="flex flex-col">
116
+ {section.title ? (
117
+ <p className="px-3 py-1 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
118
+ {section.title}
119
+ </p>
120
+ ) : null}
121
+ {(section.rows ?? []).map((row) => (
122
+ <button
123
+ key={row.id}
124
+ type="button"
125
+ disabled={!isInteractable}
126
+ onClick={() => onSelect?.({ kind: 'list', option: row })}
127
+ className="rounded-md px-3 py-1.5 text-left text-sm text-gray-900 transition-colors enabled:hover:bg-gray-100 disabled:cursor-default dark:text-gray-100 dark:enabled:hover:bg-gray-700"
128
+ >
129
+ <span className="block">{row.title}</span>
130
+ {row.description ? (
131
+ <span className="block text-xs text-gray-500 dark:text-gray-400">{row.description}</span>
132
+ ) : null}
133
+ </button>
134
+ ))}
135
+ </div>
136
+ ))}
137
+ </div>
138
+ ) : null}
139
+ </div>
140
+ ) : null}
141
+ </div>
142
+ )
143
+ }
package/src/Lightbox.tsx CHANGED
@@ -1,16 +1,31 @@
1
+ export interface LightboxLabels {
2
+ /** Texto alternativo quando a imagem não tem legenda — sem ele o leitor de tela anuncia a URL. */
3
+ imageAlt: string
4
+ close: string
5
+ }
6
+
1
7
  export interface LightboxProps {
2
8
  imageUrl: string
3
9
  caption?: string
4
10
  onClose: () => void
11
+ labels?: Partial<LightboxLabels>
5
12
  }
6
13
 
7
- export function Lightbox({ imageUrl, caption, onClose }: LightboxProps) {
14
+ export const DEFAULT_LIGHTBOX_LABELS: LightboxLabels = {
15
+ imageAlt: 'Imagem',
16
+ close: 'Fechar',
17
+ }
18
+
19
+ export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps) {
20
+ const imageAltLabel = labels?.imageAlt ?? DEFAULT_LIGHTBOX_LABELS.imageAlt
21
+ const closeLabel = labels?.close ?? DEFAULT_LIGHTBOX_LABELS.close
22
+
8
23
  return (
9
24
  <div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
10
25
  <div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
11
- <img src={imageUrl} alt={caption ?? 'Image'} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
26
+ <img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
12
27
  {caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
13
- <button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">Fechar</button>
28
+ <button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
14
29
  </div>
15
30
  </div>
16
31
  )