@adatechnology/conversations-ui 0.1.0-rc.5 β†’ 0.1.0-rc.7

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 (42) hide show
  1. package/dist/chunk-TGTBMMFC.js +1707 -0
  2. package/dist/index.d.ts +209 -22
  3. package/dist/index.js +243 -590
  4. package/dist/preview/index.d.ts +153 -9
  5. package/dist/preview/index.js +520 -38
  6. package/dist/{types-C2Yexi8A.d.ts β†’ types-B5C1DLu1.d.ts} +70 -2
  7. package/package.json +2 -2
  8. package/src/Avatar.tsx +13 -2
  9. package/src/ConversationHeader.tsx +18 -0
  10. package/src/ConversationListItem.tsx +18 -2
  11. package/src/DocumentsLibrary.tsx +322 -0
  12. package/src/EmojiPicker.tsx +69 -55
  13. package/src/FileIcon.test.ts +46 -1
  14. package/src/FileIcon.tsx +76 -10
  15. package/src/InteractiveMessage.tsx +126 -0
  16. package/src/Lightbox.tsx +18 -3
  17. package/src/MessageBubble.tsx +31 -4
  18. package/src/MessageComposer.tsx +36 -5
  19. package/src/WhatsAppMessageEditor.tsx +28 -4
  20. package/src/emojiCatalog.test.ts +35 -0
  21. package/src/emojiCatalog.ts +189 -0
  22. package/src/index.ts +25 -12
  23. package/src/lib/createMediaUrlResolver.ts +33 -0
  24. package/src/preview/AudioRecorderButton.tsx +117 -0
  25. package/src/preview/ConversationPreview.tsx +184 -15
  26. package/src/preview/MediaTypesPreview.tsx +87 -0
  27. package/src/preview/audioRecorderFormat.test.ts +67 -0
  28. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  29. package/src/preview/createMockConversationsApi.ts +62 -8
  30. package/src/preview/createPreviewWebhookClient.ts +28 -1
  31. package/src/preview/index.ts +16 -2
  32. package/src/preview/mediaTypeOf.test.ts +15 -0
  33. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  34. package/src/preview/previewFileSamples.test.ts +151 -0
  35. package/src/preview/previewFileSamples.ts +74 -0
  36. package/src/preview/previewFixtures.ts +140 -8
  37. package/src/preview/previewMediaSource.test.ts +62 -0
  38. package/src/preview/previewMediaSource.ts +91 -0
  39. package/src/providers/types.ts +17 -0
  40. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  41. package/src/types.ts +38 -1
  42. package/dist/chunk-YWITIIHD.js +0 -728
@@ -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
  }
@@ -33,6 +33,51 @@ describe('resolveFileIconExtension', () => {
33
33
  })
34
34
 
35
35
  it('devolve algo fora do mapa para tipo desconhecido, caindo no Γ­cone genΓ©rico', () => {
36
- expect(resolveFileIconExtension('lista-compras.txt', 'text/plain')).toBe('plain')
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')
37
82
  })
38
83
  })
package/src/FileIcon.tsx CHANGED
@@ -1,4 +1,13 @@
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'
2
11
 
3
12
  import { cn } from './lib/cn'
4
13
 
@@ -9,26 +18,83 @@ export interface FileIconProps {
9
18
  className?: string
10
19
  }
11
20
 
12
- 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> = {
13
35
  pdf: { Icon: FileText, colorClass: 'text-red-500' },
14
- doc: { Icon: FileText, colorClass: 'text-blue-500' },
15
- docx: { Icon: FileText, colorClass: 'text-blue-500' },
16
- xls: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
17
- 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,
18
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,
19
72
  }
20
73
 
74
+ const MEDIA_FAMILIES = new Set(['image', 'video', 'audio'])
75
+
21
76
  /**
22
- * O nome do arquivo tem precedΓͺncia sobre o mimeType porque o mapa Γ© indexado por extensΓ£o curta:
23
- * o mimeType do Office Γ© longo (`…wordprocessingml.document`) e nunca casaria, entΓ£o quem passa sΓ³
24
- * mimeType perde o Γ­cone de Word e de Excel.
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.
25
87
  *
26
88
  * Exportada para teste: Γ© a regra que jΓ‘ regrediu uma vez no painel de documentos.
27
89
  */
28
90
  export function resolveFileIconExtension(filename?: string, mimeType?: string): string {
29
91
  const fromFilename = filename?.split('.').pop()?.toLowerCase()
30
92
  if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
31
- 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 ?? ''
32
98
  }
33
99
 
34
100
  // Melhoria sobre a paridade da bolha de documento (T6.7 usava um ΓΊnico Γ­cone genΓ©rico
@@ -0,0 +1,126 @@
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
+
17
+ export interface InteractiveMessageLabels {
18
+ /** Fallback do rΓ³tulo do botΓ£o que abre a lista, quando o payload nΓ£o traz um. */
19
+ openList: string
20
+ }
21
+
22
+ export const DEFAULT_INTERACTIVE_MESSAGE_LABELS: InteractiveMessageLabels = {
23
+ openList: 'Ver opΓ§Γ΅es',
24
+ }
25
+
26
+ export interface InteractiveMessageProps {
27
+ payload: InteractivePayload
28
+ onSelect?: (selection: InteractiveSelection) => void
29
+ labels?: Partial<InteractiveMessageLabels>
30
+ className?: string
31
+ }
32
+
33
+ function collectRows(payload: InteractivePayload): InteractiveOption[] {
34
+ return (payload.action?.sections ?? []).flatMap((section) => section.rows ?? [])
35
+ }
36
+
37
+ function collectButtons(payload: InteractivePayload): InteractiveOption[] {
38
+ return (payload.action?.buttons ?? [])
39
+ .map((button) => button.reply)
40
+ .filter((reply): reply is InteractiveOption => reply !== undefined)
41
+ }
42
+
43
+ export function InteractiveMessage({ payload, onSelect, labels, className }: InteractiveMessageProps) {
44
+ const openListLabel = labels?.openList ?? DEFAULT_INTERACTIVE_MESSAGE_LABELS.openList
45
+ const [isListOpen, setIsListOpen] = useState(false)
46
+
47
+ const buttons = collectButtons(payload)
48
+ const sections = payload.action?.sections ?? []
49
+ const hasRows = collectRows(payload).length > 0
50
+ const isInteractable = onSelect !== undefined
51
+
52
+ return (
53
+ <div className={cn('flex flex-col gap-1', className)}>
54
+ {payload.header?.text ? (
55
+ <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{payload.header.text}</p>
56
+ ) : null}
57
+
58
+ {payload.body?.text ? (
59
+ <p className="whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100">
60
+ {payload.body.text}
61
+ </p>
62
+ ) : null}
63
+
64
+ {payload.footer?.text ? (
65
+ <p className="text-xs text-gray-500 dark:text-gray-400">{payload.footer.text}</p>
66
+ ) : null}
67
+
68
+ {buttons.length > 0 ? (
69
+ <div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
70
+ {buttons.map((button) => (
71
+ <button
72
+ key={button.id}
73
+ type="button"
74
+ disabled={!isInteractable}
75
+ onClick={() => onSelect?.({ kind: 'button', option: button })}
76
+ 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"
77
+ >
78
+ {button.title}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ ) : null}
83
+
84
+ {hasRows ? (
85
+ <div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
86
+ <button
87
+ type="button"
88
+ onClick={() => setIsListOpen((open) => !open)}
89
+ aria-expanded={isListOpen}
90
+ 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"
91
+ >
92
+ ☰ {payload.action?.button ?? openListLabel}
93
+ </button>
94
+
95
+ {isListOpen ? (
96
+ <div className="mt-1 flex flex-col gap-1">
97
+ {sections.map((section, sectionIndex) => (
98
+ <div key={section.title ?? sectionIndex} className="flex flex-col">
99
+ {section.title ? (
100
+ <p className="px-3 py-1 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
101
+ {section.title}
102
+ </p>
103
+ ) : null}
104
+ {(section.rows ?? []).map((row) => (
105
+ <button
106
+ key={row.id}
107
+ type="button"
108
+ disabled={!isInteractable}
109
+ onClick={() => onSelect?.({ kind: 'list', option: row })}
110
+ 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"
111
+ >
112
+ <span className="block">{row.title}</span>
113
+ {row.description ? (
114
+ <span className="block text-xs text-gray-500 dark:text-gray-400">{row.description}</span>
115
+ ) : null}
116
+ </button>
117
+ ))}
118
+ </div>
119
+ ))}
120
+ </div>
121
+ ) : null}
122
+ </div>
123
+ ) : null}
124
+ </div>
125
+ )
126
+ }
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
  )
@@ -1,12 +1,15 @@
1
- import { useState } from 'react'
1
+ import { useMemo, useState } from 'react'
2
2
  import { Check } from 'lucide-react'
3
- import type { MessagePayload } from './types'
3
+ import type { InteractiveSelection, MessagePayload } from './types'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
5
5
  import { StatusTicks } from './StatusTicks'
6
6
  import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
7
7
  import { Lightbox } from './Lightbox'
8
+ import { InteractiveMessage } from './InteractiveMessage'
8
9
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
9
10
  import { cn } from './lib/cn'
11
+ import { createMediaUrlResolver } from './lib/createMediaUrlResolver'
12
+ import { useConversations } from './providers/ConversationsProvider'
10
13
  import { formatTimestamp, formatDateTime } from './lib/format'
11
14
 
12
15
  export interface MessageBubbleProps {
@@ -17,7 +20,16 @@ export interface MessageBubbleProps {
17
20
  isSelecting?: boolean
18
21
  isSelected?: boolean
19
22
  onToggleSelect?: () => void
23
+ /**
24
+ * Como buscar a mΓ­dia da mensagem. Ausente, o balΓ£o usa o `ConversationsApi` do
25
+ * `ConversationsProvider` β€” passe apenas para sobrescrever (cache prΓ³prio, CDN, proxy do host).
26
+ */
20
27
  onResolveMediaUrl?: ResolveMediaUrl
28
+ /**
29
+ * Toque numa opΓ§Γ£o do menu interativo. Ausente, as opΓ§Γ΅es aparecem desabilitadas β€” que Γ© o certo
30
+ * no histΓ³rico da inbox: o operador vΓͺ o que foi oferecido, sem responder no lugar do cliente.
31
+ */
32
+ onInteractiveSelect?: (selection: InteractiveSelection) => void
21
33
  className?: string
22
34
  }
23
35
 
@@ -39,15 +51,26 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
39
51
  // tailwind.config do host expondo as cores `whatsapp.*` β€” ver Wallpaper.tsx e T6.2.
40
52
  export function MessageBubble({
41
53
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
42
- onResolveMediaUrl, className,
54
+ onResolveMediaUrl, onInteractiveSelect, className,
43
55
  }: MessageBubbleProps) {
44
56
  const { bubble, selection } = useConversationLocales()
45
57
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
58
+ const context = useConversations()
59
+
60
+ // Resolvedor pelo contexto quando o host nΓ£o passa um: o contrato jΓ‘ declara `getDocumentUrl` e
61
+ // `getMediaProxyUrl`, entΓ£o exigir que cada projeto ligasse esse fio sΓ³ garantia que a mΓ­dia nΓ£o
62
+ // carregasse em quem esquecesse. `useMemo` porque o resolvedor Γ© a identidade de que o
63
+ // `MediaRenderer` depende para nΓ£o refazer busca a cada render.
64
+ const resolveMediaUrl = useMemo(
65
+ () => onResolveMediaUrl ?? (context?.api ? createMediaUrlResolver(context.api) : undefined),
66
+ [onResolveMediaUrl, context?.api],
67
+ )
46
68
 
47
69
  const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
48
70
  const hasError = message.status === 'failed'
49
71
  const isMedia = MEDIA_TYPES.has(message.type)
50
72
  const isTemplate = message.type === 'template'
73
+ const isInteractive = message.type === 'interactive'
51
74
  const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
52
75
 
53
76
  const tooltipText = message.status === 'read' && message.readAt
@@ -115,7 +138,11 @@ export function MessageBubble({
115
138
  )}
116
139
 
117
140
  {isMedia ? (
118
- <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={onResolveMediaUrl} />
141
+ <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
142
+ ) : isInteractive && message.payload ? (
143
+ // O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
144
+ // sΓ³ uma cΓ³pia achatada para busca β€” renderizar `content` aqui duplicaria o corpo.
145
+ <InteractiveMessage payload={message.payload} onSelect={onInteractiveSelect} />
119
146
  ) : (
120
147
  <>
121
148
  {isTemplate && (
@@ -3,7 +3,20 @@ import type { ConversationsFeatures } from './types'
3
3
  import { cn } from './lib/cn'
4
4
  import { EmojiPicker } from './EmojiPicker'
5
5
 
6
+ export interface MessageComposerLabels {
7
+ emoji: string
8
+ attach: string
9
+ send: string
10
+ }
11
+
12
+ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
13
+ emoji: 'Emoji',
14
+ attach: 'Anexar',
15
+ send: 'Enviar',
16
+ }
17
+
6
18
  export interface MessageComposerProps {
19
+ labels?: Partial<MessageComposerLabels>
7
20
  onSend: (text: string) => void
8
21
  onAttach?: (file: File) => void
9
22
  value?: string
@@ -22,8 +35,22 @@ export interface MessageComposerClassNames {
22
35
  field: string
23
36
  }
24
37
 
25
- // Paridade com financiamento-imobiliario-bot/apps/web/src/pages/ConversationsPage.tsx:1490
26
- const DEFAULT_ACCEPTED_FILE_TYPES = 'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip'
38
+ /**
39
+ * Exatamente o que a Meta aceita em mensagem de mΓ­dia β€” imagem, sticker, Γ‘udio, vΓ­deo e a lista
40
+ * fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
41
+ * empurra a falha para depois do envio, quando jΓ‘ nΓ£o dΓ‘ para explicar ao operador o que houve.
42
+ * Produto com regra prΓ³pria passa `acceptedFileTypes`.
43
+ */
44
+ export const DEFAULT_ACCEPTED_FILE_TYPES = [
45
+ 'image/jpeg,image/png,image/webp',
46
+ 'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
47
+ 'video/mp4,video/3gpp',
48
+ 'application/pdf,text/plain,text/csv',
49
+ 'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
50
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
51
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
52
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
53
+ ].join(',')
27
54
 
28
55
  interface FilePreview {
29
56
  file: File
@@ -42,7 +69,11 @@ export const MessageComposer = ({
42
69
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
43
70
  className,
44
71
  classNames,
72
+ labels,
45
73
  }: MessageComposerProps) => {
74
+ const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
75
+ const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
76
+ const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
46
77
  const [internalText, setInternalText] = useState('')
47
78
  const [showEmoji, setShowEmoji] = useState(false)
48
79
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -168,7 +199,7 @@ export const MessageComposer = ({
168
199
  <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
169
200
  {showEmojiButton && (
170
201
  <div className="relative flex-shrink-0">
171
- <button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label="Emoji">
202
+ <button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
172
203
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
173
204
  </button>
174
205
  {showEmoji && (
@@ -194,7 +225,7 @@ export const MessageComposer = ({
194
225
  {showAttachButton && (
195
226
  <>
196
227
  <input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
197
- <button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label="Anexar">
228
+ <button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
198
229
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
199
230
  </button>
200
231
  </>
@@ -208,7 +239,7 @@ export const MessageComposer = ({
208
239
  ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
209
240
  : 'bg-gray-200 text-gray-400 cursor-not-allowed'
210
241
  }`}
211
- aria-label="Enviar"
242
+ aria-label={sendLabel}
212
243
  >
213
244
  <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
214
245
  </button>