@adatechnology/conversations-ui 0.1.0-rc.0 → 0.1.0-rc.10

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 (83) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-FWNAWAJP.js +1743 -0
  3. package/dist/flows/index.js +3 -3
  4. package/dist/index.d.ts +619 -136
  5. package/dist/index.js +1064 -1024
  6. package/dist/preview/index.d.ts +330 -0
  7. package/dist/preview/index.js +1281 -0
  8. package/dist/styles.css +198 -0
  9. package/dist/types-B5C1DLu1.d.ts +365 -0
  10. package/package.json +10 -3
  11. package/src/Avatar.tsx +30 -4
  12. package/src/ChannelIcon.tsx +87 -0
  13. package/src/ConversationContextPanel.tsx +106 -0
  14. package/src/ConversationDocumentsPanel.tsx +425 -0
  15. package/src/ConversationHeader.tsx +257 -0
  16. package/src/ConversationListItem.tsx +54 -7
  17. package/src/ConversationLocalesProvider.tsx +16 -0
  18. package/src/ConversationRow.tsx +137 -0
  19. package/src/DateDivider.tsx +16 -3
  20. package/src/DocumentsLibrary.tsx +322 -0
  21. package/src/EmojiPicker.tsx +69 -55
  22. package/src/FileIcon.test.ts +83 -0
  23. package/src/FileIcon.tsx +88 -11
  24. package/src/InteractiveMessage.test.tsx +41 -0
  25. package/src/InteractiveMessage.tsx +143 -0
  26. package/src/Lightbox.tsx +18 -3
  27. package/src/MediaRenderer.tsx +14 -11
  28. package/src/MessageBubble.tsx +54 -5
  29. package/src/MessageComposer.test.tsx +35 -0
  30. package/src/MessageComposer.tsx +73 -19
  31. package/src/Wallpaper.test.tsx +21 -0
  32. package/src/Wallpaper.tsx +54 -6
  33. package/src/WhatsAppMessageEditor.tsx +28 -4
  34. package/src/WindowExpiredNotice.tsx +57 -0
  35. package/src/conversationChannel.test.ts +53 -0
  36. package/src/conversationChannel.ts +146 -0
  37. package/src/conversationTranscript.test.ts +65 -0
  38. package/src/conversationTranscript.ts +64 -0
  39. package/src/conversationWindow.test.ts +90 -0
  40. package/src/conversationWindow.ts +78 -0
  41. package/src/emojiCatalog.test.ts +35 -0
  42. package/src/emojiCatalog.ts +189 -0
  43. package/src/flows/FlowMapCanvas.tsx +2 -2
  44. package/src/hooks/useConversationActions.ts +56 -0
  45. package/src/hooks/useConversationDocuments.ts +15 -9
  46. package/src/hooks/useConversationList.ts +15 -9
  47. package/src/hooks/useConversationMessages.ts +2 -2
  48. package/src/index.ts +112 -16
  49. package/src/lib/cn.test.ts +29 -0
  50. package/src/lib/cn.ts +15 -0
  51. package/src/lib/createMediaUrlResolver.ts +33 -0
  52. package/src/lib/paginated.test.ts +33 -0
  53. package/src/lib/paginated.ts +26 -0
  54. package/src/lib/phone.ts +34 -0
  55. package/src/preview/AudioRecorderButton.tsx +125 -0
  56. package/src/preview/ConversationPreview.tsx +315 -0
  57. package/src/preview/MediaTypesPreview.tsx +87 -0
  58. package/src/preview/audioRecorderFormat.test.ts +67 -0
  59. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  60. package/src/preview/createMockConversationsApi.ts +271 -0
  61. package/src/preview/createMockSSEProvider.ts +40 -0
  62. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  63. package/src/preview/createPreviewWebhookClient.ts +126 -0
  64. package/src/preview/index.ts +54 -0
  65. package/src/preview/mediaTypeOf.test.ts +15 -0
  66. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  67. package/src/preview/mockEventSource.ts +53 -0
  68. package/src/preview/preview.test.ts +177 -0
  69. package/src/preview/previewFileSamples.test.ts +151 -0
  70. package/src/preview/previewFileSamples.ts +74 -0
  71. package/src/preview/previewFixtures.ts +440 -0
  72. package/src/preview/previewMediaSource.test.ts +62 -0
  73. package/src/preview/previewMediaSource.ts +91 -0
  74. package/src/preview/previewStore.ts +193 -0
  75. package/src/preview/startPreviewScript.ts +60 -0
  76. package/src/providers/types.ts +163 -11
  77. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  78. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  79. package/src/styles.css +136 -0
  80. package/src/types.ts +46 -1
  81. package/src/useDarkMode.ts +26 -0
  82. package/src/useIsNarrow.ts +29 -0
  83. package/src/useWaitingNotifications.ts +74 -29
package/src/index.ts CHANGED
@@ -1,26 +1,90 @@
1
1
  export { MessageBubble } from './MessageBubble'
2
+ export { InteractiveMessage, DEFAULT_INTERACTIVE_MESSAGE_LABELS } from './InteractiveMessage'
2
3
  export { ConversationWallpaper } from './Wallpaper'
3
4
  export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
4
5
  export { AudioPlayer } from './AudioPlayer'
5
- export { EmojiPicker } from './EmojiPicker'
6
- export { MessageComposer } from './MessageComposer'
7
- export { WhatsAppMessageEditor } from './WhatsAppMessageEditor'
6
+ export { EmojiPicker, DEFAULT_EMOJI_PICKER_LABELS } from './EmojiPicker'
7
+ export { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
8
+ export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
9
+ export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
8
10
  export { SimpleEmojiPicker } from './SimpleEmojiPicker'
9
11
  export { DateDivider } from './DateDivider'
10
- export { Avatar } from './Avatar'
11
- export { ConversationListItem } from './ConversationListItem'
12
+ export { Avatar, DEFAULT_AVATAR_LABELS } from './Avatar'
13
+ export { ConversationListItem, DEFAULT_CONVERSATION_LIST_ITEM_LABELS } from './ConversationListItem'
12
14
  export { ToastProvider, useToast, toast } from './Toast'
13
15
 
14
16
  export { StatusTicks } from './StatusTicks'
15
- export { Lightbox } from './Lightbox'
17
+ export { Lightbox, DEFAULT_LIGHTBOX_LABELS } from './Lightbox'
16
18
  export { MediaRenderer } from './MediaRenderer'
17
19
  export { FileIcon } from './FileIcon'
18
20
  export { MessageText } from './MessageText'
19
21
  export { MessageTimestamp } from './MessageTimestamp'
20
22
  export { MessageTail } from './MessageTail'
21
23
 
22
- export { useDarkMode } from './useDarkMode'
24
+ // Operação da inbox: janela de 24h da Meta, linha com ações e painéis do atendimento. Regras da
25
+ // plataforma e do ofício de atender, não de um produto — por isso moram aqui.
26
+ export { CONVERSATION_WINDOW, WINDOW_FILTERS, windowOf, formatStalledFor } from './conversationWindow'
27
+ export type { WindowOfParams } from './conversationWindow'
28
+ // Canal de origem: capacidades por plataforma (janela de sessão, reabertura, tipo de identificador).
29
+ export {
30
+ CONVERSATION_CHANNEL,
31
+ DEFAULT_CONVERSATION_CHANNEL,
32
+ CHANNEL_CAPABILITIES,
33
+ channelFiltersFor,
34
+ CHANNEL_FILTER_ALL,
35
+ REOPEN_MECHANISM,
36
+ HANDLE_KIND,
37
+ capabilitiesOf,
38
+ formatContactHandle,
39
+ contactFlag,
40
+ } from './conversationChannel'
41
+ export type {
42
+ ConversationChannel,
43
+ ChannelCapabilities,
44
+ ChannelFilter,
45
+ ChannelFilterOption,
46
+ ReopenMechanism,
47
+ HandleKind,
48
+ FormatContactHandleParams,
49
+ } from './conversationChannel'
50
+ export type { ConversationWindow } from './conversationWindow'
51
+ export { ConversationRow } from './ConversationRow'
52
+ export { ChannelIcon, CHANNEL_BRAND_COLOR } from './ChannelIcon'
53
+ export type { ChannelIconProps } from './ChannelIcon'
54
+ export type { ConversationRowProps, ConversationRowClassNames } from './ConversationRow'
55
+ export { ConversationHeader, DEFAULT_CONVERSATION_HEADER_LABELS } from './ConversationHeader'
56
+ export type {
57
+ ConversationHeaderProps,
58
+ ConversationHeaderLabels,
59
+ ConversationHeaderClassNames,
60
+ } from './ConversationHeader'
61
+ export { ConversationContextPanel, DEFAULT_CONVERSATION_CONTEXT_LABELS } from './ConversationContextPanel'
62
+ export type {
63
+ ConversationContextPanelProps,
64
+ ConversationContextEntry,
65
+ ConversationContextPanelLabels,
66
+ ConversationContextPanelClassNames,
67
+ } from './ConversationContextPanel'
68
+ export { WindowExpiredNotice, isWindowBlocking, DEFAULT_WINDOW_EXPIRED_LABELS } from './WindowExpiredNotice'
69
+ export type { WindowExpiredNoticeProps, WindowExpiredNoticeLabels } from './WindowExpiredNotice'
70
+ export { DocumentsLibrary, DEFAULT_DOCUMENTS_LIBRARY_LABELS } from './DocumentsLibrary'
71
+ export type { DocumentsLibraryProps, DocumentsLibraryLabels, DocumentsLibraryClassNames } from './DocumentsLibrary'
72
+ export { DOCUMENT_SOURCE_FILTER } from './ConversationDocumentsPanel'
73
+ export type { DocumentSourceFilter } from './ConversationDocumentsPanel'
74
+ export type { ConversationDocumentsPanelClassNames } from './ConversationDocumentsPanel'
75
+ export { ConversationDocumentsPanel, DEFAULT_CONVERSATION_DOCUMENTS_LABELS } from './ConversationDocumentsPanel'
76
+ export type { ConversationDocumentsPanelProps, ConversationDocumentsPanelLabels } from './ConversationDocumentsPanel'
77
+ export { buildTranscriptText, buildTranscriptFilename, downloadTextFile } from './conversationTranscript'
78
+ export type { BuildTranscriptTextParams } from './conversationTranscript'
79
+
80
+ export { useDarkMode, useIsDarkTheme } from './useDarkMode'
81
+ export { useIsNarrow, NARROW_MAX_WIDTH_PX } from './useIsNarrow'
23
82
  export { useWaitingNotifications } from './useWaitingNotifications'
83
+ export type {
84
+ UseWaitingNotificationsLabels,
85
+ UseWaitingNotificationsParams,
86
+ UseWaitingNotificationsResult,
87
+ } from './useWaitingNotifications'
24
88
 
25
89
  export { ConversationsProvider, useConversations } from './providers/ConversationsProvider'
26
90
 
@@ -29,6 +93,11 @@ export { ConversationsProvider, useConversations } from './providers/Conversatio
29
93
  export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
30
94
  export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
31
95
  export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
96
+ export {
97
+ WhatsAppTemplatesSettings,
98
+ TEMPLATE_SETTINGS_TAB,
99
+ DEFAULT_TEMPLATES_SETTINGS_LABELS,
100
+ } from './settings/WhatsAppTemplatesSettings'
32
101
  export { TopicsForm } from './settings/TopicsForm'
33
102
 
34
103
  // Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
@@ -38,27 +107,47 @@ export { useConversationList } from './hooks/useConversationList'
38
107
  export { useConversationContext } from './hooks/useConversationContext'
39
108
  export { useConversationDocuments } from './hooks/useConversationDocuments'
40
109
  export { useConversationRealtime, useGlobalRealtime } from './hooks/useConversationRealtime'
110
+ export { useConversationActions, useInboxActions } from './hooks/useConversationActions'
41
111
 
42
112
  export { parseWhatsAppFormatting, waToHTML, htmlToWA, waToHTMLInline } from './lib/whatsapp-formatting'
43
113
  export { formatPhone, phoneInitials } from './lib/phone'
44
- export { formatTimestamp, formatFileSize } from './lib/format'
114
+ // `formatDateTime` e `isSameDay` já eram usados pelas bolhas e pelo divisor de data; exportá-los
115
+ // evita que cada host mantenha a própria cópia e acabe com timeline e transcript divergindo.
116
+ export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './lib/format'
45
117
 
46
118
  export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
47
- export type { ConversationsApi, SSEProvider, ConversationSummary, ConversationDocument } from './providers/types'
119
+ export type { InteractivePayload, InteractiveSection, InteractiveOption, InteractiveSelection } from './types'
120
+ export type {
121
+ ConversationsApi,
122
+ SSEProvider,
123
+ ConversationEventSource,
124
+ ConversationSummary,
125
+ ConversationDocument,
126
+ ConversationPage,
127
+ ConversationDocumentPage,
128
+ CompanyDocument,
129
+ CompanyDocumentPage,
130
+ ConversationTemplate,
131
+ ListConversationsParams,
132
+ ListDocumentsParams,
133
+ } from './providers/types'
134
+ export type { UseConversationActionsResult, UseInboxActionsResult } from './hooks/useConversationActions'
48
135
 
49
136
  export type { MessageBubbleProps } from './MessageBubble'
50
137
  export type { ConversationWallpaperProps } from './Wallpaper'
51
138
  export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
52
139
  export type { AudioPlayerProps } from './AudioPlayer'
53
- export type { EmojiPickerProps } from './EmojiPicker'
54
- export type { MessageComposerProps } from './MessageComposer'
55
- export type { WhatsAppMessageEditorProps } from './WhatsAppMessageEditor'
140
+ export type { EmojiPickerProps, EmojiPickerLabels } from './EmojiPicker'
141
+ export type { EmojiEntry, EmojiCategory } from './emojiCatalog'
142
+ export type { InteractiveMessageProps, InteractiveMessageLabels } from './InteractiveMessage'
143
+ export type { MessageComposerProps, MessageComposerClassNames, MessageComposerLabels } from './MessageComposer'
144
+ export type { WhatsAppMessageEditorProps, WhatsAppMessageEditorLabels } from './WhatsAppMessageEditor'
56
145
  export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
57
- export type { DateDividerProps } from './DateDivider'
58
- export type { AvatarProps } from './Avatar'
59
- export type { ConversationListItemProps } from './ConversationListItem'
146
+ export type { DateDividerProps, DateDividerClassNames } from './DateDivider'
147
+ export type { AvatarProps, AvatarLabels } from './Avatar'
148
+ export type { ConversationListItemProps, ConversationListItemLabels } from './ConversationListItem'
60
149
  export type { StatusTicksProps } from './StatusTicks'
61
- export type { LightboxProps } from './Lightbox'
150
+ export type { LightboxProps, LightboxLabels } from './Lightbox'
62
151
  export type { MediaRendererProps, ResolveMediaUrl } from './MediaRenderer'
63
152
  export type { FileIconProps } from './FileIcon'
64
153
  export type { MessageTextProps } from './MessageText'
@@ -77,6 +166,11 @@ export type {
77
166
  WhatsAppCreateTemplateFormLabels,
78
167
  } from './settings/WhatsAppCreateTemplateForm'
79
168
  export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
169
+ export type {
170
+ WhatsAppTemplatesSettingsProps,
171
+ WhatsAppTemplatesSettingsLabels,
172
+ TemplateSettingsTab,
173
+ } from './settings/WhatsAppTemplatesSettings'
80
174
  export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/TopicsForm'
81
175
 
82
176
  export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
@@ -85,3 +179,5 @@ export type { UseConversationContextResult } from './hooks/useConversationContex
85
179
  export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
86
180
  export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
87
181
  export type { AsyncResourceState } from './hooks/useAsyncResource'
182
+ export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
183
+ export type { ConversationHeaderUtility } from './ConversationHeader'
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { cn } from './cn'
4
+
5
+ describe('cn', () => {
6
+ // O contrato de `classNames`/`className` dos componentes depende disto: se o override não vencer,
7
+ // a API de customização é decorativa. Concatenar falharia justo no caso comum — o Tailwind emite
8
+ // `px-2` antes de `px-4`, então a base ganharia de quem quer apertar o espaçamento.
9
+ it('o override do host vence a classe base em conflito', () => {
10
+ expect(cn('px-4 py-3', 'px-2')).toBe('py-3 px-2')
11
+ })
12
+
13
+ it('mantém o que não conflita', () => {
14
+ expect(cn('flex items-center gap-3', 'gap-1')).toBe('flex items-center gap-1')
15
+ })
16
+
17
+ it('preserva classes próprias do pacote, que o merge não conhece', () => {
18
+ expect(cn('cv-row flex', 'bg-red-50')).toBe('cv-row flex bg-red-50')
19
+ })
20
+
21
+ it('ignora ausente, falso e vazio', () => {
22
+ expect(cn('border-b', undefined, false, '')).toBe('border-b')
23
+ })
24
+
25
+ it('aplica condicional na ordem recebida', () => {
26
+ const isCompact = false
27
+ expect(cn('mt-2', isCompact && 'mt-8', 'mt-4')).toBe('mt-4')
28
+ })
29
+ })
package/src/lib/cn.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Junta classes resolvendo conflitos de utilitário do Tailwind.
3
+ *
4
+ * Concatenar não basta: a ordem no atributo `class` não decide nada, quem decide é a ordem no CSS
5
+ * gerado. Com `px-4` na base e `px-2` vindo do host, o Tailwind emite `px-2` antes de `px-4` e a
6
+ * base ganharia — justo o caso de quem quer apertar o espaçamento. O `twMerge` descarta a classe
7
+ * base quando o host manda uma da mesma família, então o override do produto sempre vence.
8
+ */
9
+
10
+ import { clsx, type ClassValue } from 'clsx'
11
+ import { twMerge } from 'tailwind-merge'
12
+
13
+ export function cn(...inputs: ClassValue[]): string {
14
+ return twMerge(clsx(inputs))
15
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolve a mídia de uma mensagem numa URL exibível, usando só o `ConversationsApi`.
3
+ *
4
+ * Mora no pacote, e não em cada host, porque a regra não tem nada de específico de produto: é a
5
+ * tradução de `uploadId`/`mediaId` pelos dois métodos que o próprio contrato já declara. Deixá-la no
6
+ * host significava que todo projeto que adotasse o SDK reescreveria as mesmas oito linhas — e, na
7
+ * prática, ninguém escrevia: o `MediaRenderer` só busca mídia pela porta `onResolveMediaUrl`, então
8
+ * onde nada era injetado foto, vídeo e áudio ficavam no placeholder para sempre.
9
+ */
10
+
11
+ import type { MessagePayload } from '../types'
12
+ import type { ConversationsApi } from '../providers/types'
13
+ import type { ResolveMediaUrl } from '../MediaRenderer'
14
+
15
+ export function createMediaUrlResolver(
16
+ api: Pick<ConversationsApi, 'getDocumentUrl' | 'getMediaProxyUrl'>,
17
+ ): ResolveMediaUrl {
18
+ return async (message: MessagePayload): Promise<string | null> => {
19
+ // Mídia já copiada para o storage do host: sai por URL assinada e o binário não passa pela API.
20
+ // `inline` porque aqui o arquivo é para VER na tela — `attachment` faria o navegador baixar.
21
+ if (message.uploadId) return api.getDocumentUrl(message.uploadId, 'inline')
22
+
23
+ // Antes da ingestão só existe o id na Meta, cuja URL expira; o backend busca e devolve base64.
24
+ // Data URL serve de `src` para `<img>`/`<video>`/`<audio>`: o bloqueio do Chrome a `data:` vale
25
+ // para navegação de topo, não para carregar mídia dentro da página.
26
+ if (message.mediaId) {
27
+ const { mimeType, data } = await api.getMediaProxyUrl(message.mediaId)
28
+ return `data:${mimeType};base64,${data}`
29
+ }
30
+
31
+ return null
32
+ }
33
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { conversationsOf, documentsOf, totalOf } from './paginated'
3
+ import type { ConversationDocument, ConversationSummary } from '../providers/types'
4
+
5
+ const conversation = { id: '1', whatsappNumber: '5511900000000' } as ConversationSummary
6
+ const document = { id: 'd1', filename: 'contrato.pdf' } as ConversationDocument
7
+
8
+ describe('normalização de lista ou página', () => {
9
+ // As duas formas existem porque o contrato aceita as duas: array puro é o retorno original, e
10
+ // implementações antigas continuam válidas. Um consumidor que só tratasse uma delas quebraria
11
+ // em runtime, não no compilador.
12
+ it('aceita o array puro do contrato original', () => {
13
+ expect(conversationsOf([conversation])).toEqual([conversation])
14
+ expect(documentsOf([document])).toEqual([document])
15
+ })
16
+
17
+ it('desembrulha a forma paginada', () => {
18
+ expect(conversationsOf({ conversations: [conversation], total: 42 })).toEqual([conversation])
19
+ expect(documentsOf({ documents: [document], total: 7 })).toEqual([document])
20
+ })
21
+
22
+ it('usa o total do servidor quando ele vem, e o tamanho da página quando não vem', () => {
23
+ // A distinção importa: com array puro não dá para saber se a página é a última, então o
24
+ // melhor palpite honesto é o que se tem em mãos.
25
+ expect(totalOf({ conversations: [conversation], total: 42 })).toBe(42)
26
+ expect(totalOf([conversation])).toBe(1)
27
+ })
28
+
29
+ it('trata página vazia sem confundir com ausência de dados', () => {
30
+ expect(conversationsOf({ conversations: [], total: 0 })).toEqual([])
31
+ expect(totalOf({ conversations: [], total: 0 })).toBe(0)
32
+ })
33
+ })
@@ -0,0 +1,26 @@
1
+ import type {
2
+ ConversationDocument,
3
+ ConversationDocumentPage,
4
+ ConversationPage,
5
+ ConversationSummary,
6
+ } from '../providers/types'
7
+
8
+ /**
9
+ * `fetchConversations` e `getDocuments` devolvem o array puro (contrato original) ou uma página
10
+ * com total. Normalizar num lugar só evita que cada consumidor invente sua própria checagem —
11
+ * e um consumidor esquecido não falha no compilador, falha em runtime com `.map is not a
12
+ * function`.
13
+ */
14
+ export function conversationsOf(result: ConversationSummary[] | ConversationPage): ConversationSummary[] {
15
+ return Array.isArray(result) ? result : result.conversations
16
+ }
17
+
18
+ export function documentsOf(result: ConversationDocument[] | ConversationDocumentPage): ConversationDocument[] {
19
+ return Array.isArray(result) ? result : result.documents
20
+ }
21
+
22
+ export function totalOf(
23
+ result: ConversationSummary[] | ConversationPage | ConversationDocument[] | ConversationDocumentPage,
24
+ ): number {
25
+ return Array.isArray(result) ? result.length : result.total
26
+ }
package/src/lib/phone.ts CHANGED
@@ -20,6 +20,40 @@ export function formatPhone(number: string): string {
20
20
  return number
21
21
  }
22
22
 
23
+ // Mapa explícito em vez de derivar o emoji do código: prefixos são ambíguos (1 = EUA e Canadá,
24
+ // 7 = Rússia e Cazaquistão) e mostrar a bandeira errada é pior do que não mostrar nenhuma.
25
+ const COUNTRY_FLAG_BY_DIAL_CODE: Readonly<Record<string, string>> = {
26
+ '55': '🇧🇷',
27
+ '351': '🇵🇹',
28
+ '34': '🇪🇸',
29
+ '54': '🇦🇷',
30
+ '56': '🇨🇱',
31
+ '57': '🇨🇴',
32
+ '52': '🇲🇽',
33
+ '598': '🇺🇾',
34
+ '595': '🇵🇾',
35
+ '44': '🇬🇧',
36
+ '49': '🇩🇪',
37
+ '39': '🇮🇹',
38
+ '33': '🇫🇷',
39
+ }
40
+
41
+ /**
42
+ * Devolve string vazia quando não reconhece o país — quem renderiza decide se some com o espaço.
43
+ */
44
+ export function phoneCountryFlag(number: string): string {
45
+ const digits = number.replace(/\D/g, '')
46
+
47
+ // Do prefixo mais longo para o mais curto: '55' casaria antes de '551' e daria bandeira errada
48
+ // em países de código de três dígitos.
49
+ for (const length of [3, 2, 1]) {
50
+ const flag = COUNTRY_FLAG_BY_DIAL_CODE[digits.slice(0, length)]
51
+ if (flag) return flag
52
+ }
53
+
54
+ return ''
55
+ }
56
+
23
57
  export function phoneInitials(number: string): string {
24
58
  const digits = number.replace(/\D/g, '')
25
59
  return digits.slice(-2)
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Gravação de áudio no simulador, pelo microfone do próprio navegador.
3
+ *
4
+ * Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
5
+ * poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
6
+ *
7
+ * O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
8
+ * hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
9
+ */
10
+
11
+ import { useCallback, useRef, useState } from 'react'
12
+
13
+ export interface AudioRecorderButtonLabels {
14
+ start: string
15
+ stop: string
16
+ unsupported: string
17
+ denied: string
18
+ }
19
+
20
+ export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
21
+ start: 'Gravar áudio',
22
+ stop: 'Parar gravação',
23
+ unsupported: 'Este navegador não grava áudio.',
24
+ denied: 'Sem permissão para usar o microfone.',
25
+ }
26
+
27
+ export interface AudioRecorderButtonProps {
28
+ onRecorded: (file: File) => void | Promise<void>
29
+ onFailure?: (message: string) => void
30
+ labels?: Partial<AudioRecorderButtonLabels>
31
+ disabled?: boolean
32
+ }
33
+
34
+ /**
35
+ * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
36
+ * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
37
+ * do envio que o formato é inválido é pior do que gravar já no formato certo.
38
+ */
39
+ const RECORDING_FORMATS = [
40
+ { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
41
+ { mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
42
+ { mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
43
+ ] as const
44
+
45
+ export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
46
+
47
+ /** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
48
+ export function resolveRecordingFormat(): RecordingFormat | undefined {
49
+ if (typeof MediaRecorder === 'undefined') return undefined
50
+ // `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
51
+ if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
52
+ return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
53
+ }
54
+
55
+ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }: AudioRecorderButtonProps) {
56
+ const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start
57
+ const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop
58
+ const [isRecording, setIsRecording] = useState(false)
59
+ const recorderRef = useRef<MediaRecorder | null>(null)
60
+
61
+ const stop = useCallback(() => {
62
+ recorderRef.current?.stop()
63
+ }, [])
64
+
65
+ const start = useCallback(async () => {
66
+ const format = resolveRecordingFormat()
67
+ if (!format || !navigator.mediaDevices?.getUserMedia) {
68
+ onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
69
+ return
70
+ }
71
+
72
+ try {
73
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
74
+ const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
75
+ const chunks: Blob[] = []
76
+
77
+ recorder.addEventListener('dataavailable', (event) => {
78
+ if (event.data.size > 0) chunks.push(event.data)
79
+ })
80
+ recorder.addEventListener('stop', () => {
81
+ // Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
82
+ // aceso depois do envio, e o operador acha que o simulador continua ouvindo.
83
+ stream.getTracks().forEach((track) => track.stop())
84
+ setIsRecording(false)
85
+ recorderRef.current = null
86
+ // O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
87
+ // gravador, mas quem valida upload compara com `audio/ogg` puro.
88
+ const blob = new Blob(chunks, { type: format.uploadMimeType })
89
+ void onRecorded(
90
+ new File([blob], `audio-${Date.now()}.${format.extension}`, { type: format.uploadMimeType }),
91
+ )
92
+ })
93
+
94
+ recorderRef.current = recorder
95
+ recorder.start()
96
+ setIsRecording(true)
97
+ } catch {
98
+ onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
99
+ }
100
+ }, [labels?.denied, labels?.unsupported, onFailure, onRecorded])
101
+
102
+ return (
103
+ <button
104
+ type="button"
105
+ disabled={disabled}
106
+ onClick={() => (isRecording ? stop() : void start())}
107
+ title={isRecording ? stopLabel : startLabel}
108
+ aria-label={isRecording ? stopLabel : startLabel}
109
+ aria-pressed={isRecording}
110
+ /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
111
+ vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
112
+ className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${
113
+ isRecording
114
+ ? 'bg-red-500 text-white hover:bg-red-600'
115
+ : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
116
+ }`}
117
+ >
118
+ {isRecording ? (
119
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
120
+ ) : (
121
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
122
+ )}
123
+ </button>
124
+ )
125
+ }