@adatechnology/conversations-ui 0.0.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 (65) hide show
  1. package/dist/channel/index.d.ts +2 -0
  2. package/dist/channel/index.js +0 -0
  3. package/dist/chunk-ZDURDZTM.js +199 -0
  4. package/dist/flows/index.d.ts +246 -0
  5. package/dist/flows/index.js +1110 -0
  6. package/dist/index.d.ts +542 -0
  7. package/dist/index.js +2077 -0
  8. package/dist/styles.css +11 -0
  9. package/dist/styles.d.ts +2 -0
  10. package/package.json +52 -0
  11. package/src/AudioPlayer.tsx +103 -0
  12. package/src/Avatar.tsx +63 -0
  13. package/src/ConversationListItem.tsx +147 -0
  14. package/src/ConversationLocalesProvider.tsx +76 -0
  15. package/src/DateDivider.tsx +32 -0
  16. package/src/EmojiPicker.tsx +77 -0
  17. package/src/FileIcon.tsx +33 -0
  18. package/src/Lightbox.tsx +17 -0
  19. package/src/MediaRenderer.tsx +158 -0
  20. package/src/MessageBubble.tsx +129 -0
  21. package/src/MessageComposer.tsx +211 -0
  22. package/src/MessageTail.tsx +18 -0
  23. package/src/MessageText.tsx +42 -0
  24. package/src/MessageTimestamp.tsx +22 -0
  25. package/src/SimpleEmojiPicker.tsx +72 -0
  26. package/src/StatusTicks.tsx +39 -0
  27. package/src/Toast.tsx +140 -0
  28. package/src/Wallpaper.tsx +13 -0
  29. package/src/WhatsAppMessageEditor.tsx +142 -0
  30. package/src/channel/index.ts +1 -0
  31. package/src/conversations/index.ts +1 -0
  32. package/src/flows/FlowGroupFrame.tsx +21 -0
  33. package/src/flows/FlowGroupHeader.tsx +31 -0
  34. package/src/flows/FlowMapCanvas.tsx +76 -0
  35. package/src/flows/FlowMapNode.tsx +39 -0
  36. package/src/flows/FlowNodeCard.tsx +150 -0
  37. package/src/flows/FlowNodePanel.tsx +356 -0
  38. package/src/flows/FlowPalette.tsx +137 -0
  39. package/src/flows/FlowPortalNode.tsx +30 -0
  40. package/src/flows/FlowWhatsAppPreview.tsx +67 -0
  41. package/src/flows/flowGraph.ts +391 -0
  42. package/src/flows/index.ts +55 -0
  43. package/src/flows/labels.ts +187 -0
  44. package/src/hooks/useAsyncResource.ts +38 -0
  45. package/src/hooks/useConversationContext.ts +23 -0
  46. package/src/hooks/useConversationDocuments.ts +33 -0
  47. package/src/hooks/useConversationList.ts +32 -0
  48. package/src/hooks/useConversationMessages.ts +64 -0
  49. package/src/hooks/useConversationRealtime.ts +50 -0
  50. package/src/index.ts +87 -0
  51. package/src/lib/format.ts +32 -0
  52. package/src/lib/phone.ts +26 -0
  53. package/src/lib/whatsapp-formatting.tsx +215 -0
  54. package/src/providers/ConversationsProvider.tsx +29 -0
  55. package/src/providers/types.ts +54 -0
  56. package/src/settings/TopicsForm.tsx +109 -0
  57. package/src/settings/WelcomeFarewellForm.tsx +118 -0
  58. package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
  59. package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
  60. package/src/styles.css +21 -0
  61. package/src/theme.ts +30 -0
  62. package/src/types.ts +44 -0
  63. package/src/useDarkMode.ts +48 -0
  64. package/src/useWaitingNotifications.ts +64 -0
  65. package/tsconfig.json +15 -0
@@ -0,0 +1,33 @@
1
+ import { useConversations } from '../providers/ConversationsProvider'
2
+ import { useAsyncResource } from './useAsyncResource'
3
+ import type { ConversationDocument } from '../providers/types'
4
+
5
+ export interface UseConversationDocumentsParams {
6
+ search?: string
7
+ page?: number
8
+ }
9
+
10
+ export interface UseConversationDocumentsResult {
11
+ documents: ConversationDocument[]
12
+ loading: boolean
13
+ error: Error | undefined
14
+ refetch: () => Promise<void>
15
+ }
16
+
17
+ export function useConversationDocuments(
18
+ conversationId: string,
19
+ params?: UseConversationDocumentsParams,
20
+ ): UseConversationDocumentsResult {
21
+ const context = useConversations()
22
+ if (!context) {
23
+ throw new Error('useConversationDocuments requires an ancestor <ConversationsProvider>')
24
+ }
25
+ const { api } = context
26
+
27
+ const { data, loading, error, refetch } = useAsyncResource(
28
+ () => api.getDocuments(conversationId, params),
29
+ [conversationId, params?.search, params?.page],
30
+ )
31
+
32
+ return { documents: data ?? [], loading, error, refetch }
33
+ }
@@ -0,0 +1,32 @@
1
+ import { useConversations } from '../providers/ConversationsProvider'
2
+ import { useAsyncResource } from './useAsyncResource'
3
+ import type { ConversationSummary } from '../providers/types'
4
+
5
+ export interface UseConversationListParams {
6
+ page?: number
7
+ limit?: number
8
+ waitingHuman?: boolean
9
+ search?: string
10
+ }
11
+
12
+ export interface UseConversationListResult {
13
+ conversations: ConversationSummary[]
14
+ loading: boolean
15
+ error: Error | undefined
16
+ refetch: () => Promise<void>
17
+ }
18
+
19
+ export function useConversationList(params?: UseConversationListParams): UseConversationListResult {
20
+ const context = useConversations()
21
+ if (!context) {
22
+ throw new Error('useConversationList requires an ancestor <ConversationsProvider>')
23
+ }
24
+ const { api } = context
25
+
26
+ const { data, loading, error, refetch } = useAsyncResource(
27
+ () => api.fetchConversations(params),
28
+ [params?.page, params?.limit, params?.waitingHuman, params?.search],
29
+ )
30
+
31
+ return { conversations: data ?? [], loading, error, refetch }
32
+ }
@@ -0,0 +1,64 @@
1
+ import { useCallback } from 'react'
2
+ import { useConversations } from '../providers/ConversationsProvider'
3
+ import { useAsyncResource } from './useAsyncResource'
4
+ import type { MessagePayload } from '../types'
5
+
6
+ export interface UseConversationMessagesResult {
7
+ messages: MessagePayload[]
8
+ loading: boolean
9
+ error: Error | undefined
10
+ refetch: () => Promise<void>
11
+ sendMessage: (text: string) => Promise<MessagePayload>
12
+ sendMedia: (data: { base64: string; mimeType: string; filename: string; caption?: string }) => Promise<MessagePayload>
13
+ sendTemplate: (data: { templateName: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
14
+ markRead: () => Promise<void>
15
+ }
16
+
17
+ // Camada headless: dados + ações de uma conversa, sem nenhuma tela acoplada — o produto
18
+ // consome este hook e monta a UI que quiser (ou usa <MessageBubble>/<MessageComposer>
19
+ // por cima, como o pacote já oferece). Requer <ConversationsProvider> como ancestral.
20
+ export function useConversationMessages(
21
+ conversationId: string,
22
+ params?: { limit?: number; before?: string },
23
+ ): UseConversationMessagesResult {
24
+ const context = useConversations()
25
+ if (!context) {
26
+ throw new Error('useConversationMessages requires an ancestor <ConversationsProvider>')
27
+ }
28
+ const { api } = context
29
+
30
+ const { data, loading, error, refetch } = useAsyncResource(
31
+ () => api.fetchMessages(conversationId, params),
32
+ [conversationId, params?.limit, params?.before],
33
+ )
34
+
35
+ const sendMessage = useCallback(
36
+ async (text: string) => {
37
+ const message = await api.sendMessage(conversationId, text)
38
+ await refetch()
39
+ return message
40
+ },
41
+ [api, conversationId, refetch],
42
+ )
43
+
44
+ const sendMedia = useCallback(
45
+ async (mediaData: { base64: string; mimeType: string; filename: string; caption?: string }) => {
46
+ const message = await api.sendMedia(conversationId, mediaData)
47
+ await refetch()
48
+ return message
49
+ },
50
+ [api, conversationId, refetch],
51
+ )
52
+
53
+ const sendTemplate = useCallback(
54
+ async (templateData: { templateName: string; languageCode?: string; bodyParams?: string[] }) => {
55
+ await api.sendTemplate(conversationId, templateData)
56
+ await refetch()
57
+ },
58
+ [api, conversationId, refetch],
59
+ )
60
+
61
+ const markRead = useCallback(() => api.markRead(conversationId), [api, conversationId])
62
+
63
+ return { messages: data ?? [], loading, error, refetch, sendMessage, sendMedia, sendTemplate, markRead }
64
+ }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { useConversations } from '../providers/ConversationsProvider'
3
+
4
+ export type ConversationRealtimeHandler = (event: MessageEvent) => void
5
+
6
+ // Assina o SSE de uma conversa via a porta SSEProvider injetada no ConversationsProvider —
7
+ // o pacote nunca abre a conexão diretamente contra um endpoint fixo. Reconecta ao trocar
8
+ // de conversationId e sempre fecha a EventSource anterior no cleanup.
9
+ export function useConversationRealtime(
10
+ conversationId: string | undefined,
11
+ onEvent: ConversationRealtimeHandler,
12
+ ): void {
13
+ const context = useConversations()
14
+ const onEventRef = useRef(onEvent)
15
+ onEventRef.current = onEvent
16
+
17
+ useEffect(() => {
18
+ if (!context || !conversationId) return
19
+
20
+ const source = context.sse.connectConversationStream(conversationId)
21
+ const handler = (event: MessageEvent) => onEventRef.current(event)
22
+ source.addEventListener('message', handler)
23
+
24
+ return () => {
25
+ source.removeEventListener('message', handler)
26
+ source.close()
27
+ }
28
+ }, [context, conversationId])
29
+ }
30
+
31
+ // Assina o stream global (ex: novas conversas entrando na fila, notificações cross-conversa)
32
+ // — mesma porta SSEProvider, sem conversationId.
33
+ export function useGlobalRealtime(onEvent: ConversationRealtimeHandler): void {
34
+ const context = useConversations()
35
+ const onEventRef = useRef(onEvent)
36
+ onEventRef.current = onEvent
37
+
38
+ useEffect(() => {
39
+ if (!context) return
40
+
41
+ const source = context.sse.connectGlobalStream()
42
+ const handler = (event: MessageEvent) => onEventRef.current(event)
43
+ source.addEventListener('message', handler)
44
+
45
+ return () => {
46
+ source.removeEventListener('message', handler)
47
+ source.close()
48
+ }
49
+ }, [context])
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,87 @@
1
+ export { MessageBubble } from './MessageBubble'
2
+ export { ConversationWallpaper } from './Wallpaper'
3
+ export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
4
+ export { AudioPlayer } from './AudioPlayer'
5
+ export { EmojiPicker } from './EmojiPicker'
6
+ export { MessageComposer } from './MessageComposer'
7
+ export { WhatsAppMessageEditor } from './WhatsAppMessageEditor'
8
+ export { SimpleEmojiPicker } from './SimpleEmojiPicker'
9
+ export { DateDivider } from './DateDivider'
10
+ export { Avatar } from './Avatar'
11
+ export { ConversationListItem } from './ConversationListItem'
12
+ export { ToastProvider, useToast, toast } from './Toast'
13
+
14
+ export { StatusTicks } from './StatusTicks'
15
+ export { Lightbox } from './Lightbox'
16
+ export { MediaRenderer } from './MediaRenderer'
17
+ export { FileIcon } from './FileIcon'
18
+ export { MessageText } from './MessageText'
19
+ export { MessageTimestamp } from './MessageTimestamp'
20
+ export { MessageTail } from './MessageTail'
21
+
22
+ export { useDarkMode } from './useDarkMode'
23
+ export { useWaitingNotifications } from './useWaitingNotifications'
24
+
25
+ export { ConversationsProvider, useConversations } from './providers/ConversationsProvider'
26
+
27
+ // Telas de Settings (T7.1) — apresentacionais, sem chamada de rede própria; o host busca
28
+ // dados (templates da Meta, mensagens salvas) e injeta via props.
29
+ export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
30
+ export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
31
+ export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
32
+ export { TopicsForm } from './settings/TopicsForm'
33
+
34
+ // Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
35
+ // produto montar sua própria UI sobre eles. Requerem <ConversationsProvider> como ancestral.
36
+ export { useConversationMessages } from './hooks/useConversationMessages'
37
+ export { useConversationList } from './hooks/useConversationList'
38
+ export { useConversationContext } from './hooks/useConversationContext'
39
+ export { useConversationDocuments } from './hooks/useConversationDocuments'
40
+ export { useConversationRealtime, useGlobalRealtime } from './hooks/useConversationRealtime'
41
+
42
+ export { parseWhatsAppFormatting, waToHTML, htmlToWA, waToHTMLInline } from './lib/whatsapp-formatting'
43
+ export { formatPhone, phoneInitials } from './lib/phone'
44
+ export { formatTimestamp, formatFileSize } from './lib/format'
45
+
46
+ export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
47
+ export type { ConversationsApi, SSEProvider, ConversationSummary, ConversationDocument } from './providers/types'
48
+
49
+ export type { MessageBubbleProps } from './MessageBubble'
50
+ export type { ConversationWallpaperProps } from './Wallpaper'
51
+ export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
52
+ export type { AudioPlayerProps } from './AudioPlayer'
53
+ export type { EmojiPickerProps } from './EmojiPicker'
54
+ export type { MessageComposerProps } from './MessageComposer'
55
+ export type { WhatsAppMessageEditorProps } from './WhatsAppMessageEditor'
56
+ export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
57
+ export type { DateDividerProps } from './DateDivider'
58
+ export type { AvatarProps } from './Avatar'
59
+ export type { ConversationListItemProps } from './ConversationListItem'
60
+ export type { StatusTicksProps } from './StatusTicks'
61
+ export type { LightboxProps } from './Lightbox'
62
+ export type { MediaRendererProps, ResolveMediaUrl } from './MediaRenderer'
63
+ export type { FileIconProps } from './FileIcon'
64
+ export type { MessageTextProps } from './MessageText'
65
+
66
+ export type {
67
+ WhatsAppTemplateSettingsFormProps,
68
+ WhatsAppTemplateSummary,
69
+ WhatsAppTemplateVariableSuggestion,
70
+ WhatsAppTemplateSettingsFormLabels,
71
+ } from './settings/WhatsAppTemplateSettingsForm'
72
+ export type {
73
+ WhatsAppCreateTemplateFormProps,
74
+ WhatsAppCreateTemplateState,
75
+ WhatsAppCreateTemplateResult,
76
+ WhatsAppTemplateHeaderType,
77
+ WhatsAppCreateTemplateFormLabels,
78
+ } from './settings/WhatsAppCreateTemplateForm'
79
+ export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
80
+ export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/TopicsForm'
81
+
82
+ export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
83
+ export type { UseConversationListParams, UseConversationListResult } from './hooks/useConversationList'
84
+ export type { UseConversationContextResult } from './hooks/useConversationContext'
85
+ export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
86
+ export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
87
+ export type { AsyncResourceState } from './hooks/useAsyncResource'
@@ -0,0 +1,32 @@
1
+ export function formatTimestamp(timestamp: string): string {
2
+ try {
3
+ const date = new Date(timestamp)
4
+ const hours = date.getHours().toString().padStart(2, '0')
5
+ const minutes = date.getMinutes().toString().padStart(2, '0')
6
+ return `${hours}:${minutes}`
7
+ } catch {
8
+ return timestamp
9
+ }
10
+ }
11
+
12
+ export function formatDateTime(iso: string): string {
13
+ const d = new Date(iso)
14
+ return d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
15
+ }
16
+
17
+ export function isSameDay(a: Date, b: Date): boolean {
18
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
19
+ }
20
+
21
+ const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB'] as const
22
+
23
+ export function formatFileSize(bytes: number): string {
24
+ if (!isFinite(bytes) || bytes < 0) return ''
25
+ if (bytes < 1) return '0 B'
26
+
27
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), FILE_SIZE_UNITS.length - 1)
28
+ const value = bytes / 1024 ** exponent
29
+ const formatted = exponent === 0 ? value.toString() : value.toFixed(value < 10 ? 1 : 0)
30
+
31
+ return `${formatted} ${FILE_SIZE_UNITS[exponent]}`
32
+ }
@@ -0,0 +1,26 @@
1
+ export function formatPhone(number: string): string {
2
+ const digits = number.replace(/\D/g, '')
3
+
4
+ if (digits.length === 13) {
5
+ return `+${digits.slice(0, 2)} (${digits.slice(2, 4)}) ${digits.slice(4, 9)}-${digits.slice(9, 13)}`
6
+ }
7
+
8
+ if (digits.length === 12) {
9
+ return `+${digits.slice(0, 2)} (${digits.slice(2, 4)}) ${digits.slice(4, 8)}-${digits.slice(8, 12)}`
10
+ }
11
+
12
+ if (digits.length === 11) {
13
+ return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7, 11)}`
14
+ }
15
+
16
+ if (digits.length === 10) {
17
+ return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6, 10)}`
18
+ }
19
+
20
+ return number
21
+ }
22
+
23
+ export function phoneInitials(number: string): string {
24
+ const digits = number.replace(/\D/g, '')
25
+ return digits.slice(-2)
26
+ }
@@ -0,0 +1,215 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ interface FormatToken {
4
+ type: 'text' | 'bold' | 'italic' | 'strikethrough' | 'monospace' | 'codeblock'
5
+ content: string
6
+ }
7
+
8
+ const MONOSPACE_REGEX = /```([\s\S]*?)```/g
9
+ const BOLD_REGEX = /\*([^*]+)\*/g
10
+ const ITALIC_REGEX = /_([^_]+)_/g
11
+ const STRIKETHROUGH_REGEX = /~([^~]+)~/g
12
+ const INLINE_CODE_REGEX = /`([^`]+)`/g
13
+
14
+ function tokenize(text: string): FormatToken[] {
15
+ const tokens: FormatToken[] = []
16
+
17
+ let remaining = text
18
+
19
+ const codeBlocks: { index: number; content: string }[] = []
20
+ remaining = remaining.replace(MONOSPACE_REGEX, (_match, content, offset) => {
21
+ codeBlocks.push({ index: offset, content })
22
+ return '\u0000'.repeat(_match.length)
23
+ })
24
+
25
+ let codeBlockIndex = 0
26
+ let i = 0
27
+ let buffer = ''
28
+
29
+ while (i < remaining.length) {
30
+ if (remaining[i] === '\u0000') {
31
+ if (buffer) {
32
+ tokens.push(...parseInlineTokens(buffer))
33
+ buffer = ''
34
+ }
35
+ const cb = codeBlocks[codeBlockIndex++]
36
+ tokens.push({ type: 'codeblock', content: cb.content })
37
+ const skip = '```' + cb.content + '```'
38
+ i += skip.length
39
+ continue
40
+ }
41
+ // Check for inline code
42
+ const inlineMatch = remaining.slice(i).match(/^`([^`]+)`/)
43
+ if (inlineMatch && inlineMatch.index === 0) {
44
+ if (buffer) {
45
+ tokens.push(...parseInlineTokens(buffer))
46
+ buffer = ''
47
+ }
48
+ tokens.push({ type: 'monospace', content: inlineMatch[1] })
49
+ i += inlineMatch[0].length
50
+ continue
51
+ }
52
+
53
+ buffer += remaining[i]
54
+ i++
55
+ }
56
+
57
+ if (buffer) {
58
+ tokens.push(...parseInlineTokens(buffer))
59
+ }
60
+
61
+ return tokens
62
+ }
63
+
64
+ function parseInlineTokens(text: string): FormatToken[] {
65
+ const result: FormatToken[] = []
66
+ let remaining = text
67
+
68
+ while (remaining.length > 0) {
69
+ const boldMatch = remaining.match(/^\*([^*]+)\*/)
70
+ if (boldMatch) {
71
+ result.push({ type: 'bold', content: boldMatch[1] })
72
+ remaining = remaining.slice(boldMatch[0].length)
73
+ continue
74
+ }
75
+ const italicMatch = remaining.match(/^_([^_]+)_/)
76
+ if (italicMatch) {
77
+ result.push({ type: 'italic', content: italicMatch[1] })
78
+ remaining = remaining.slice(italicMatch[0].length)
79
+ continue
80
+ }
81
+ const strikeMatch = remaining.match(/^~([^~]+)~/)
82
+ if (strikeMatch) {
83
+ result.push({ type: 'strikethrough', content: strikeMatch[1] })
84
+ remaining = remaining.slice(strikeMatch[0].length)
85
+ continue
86
+ }
87
+
88
+ const nextSpecial = remaining.search(/[*_~`]/)
89
+ if (nextSpecial === -1) {
90
+ if (remaining) result.push({ type: 'text', content: remaining })
91
+ break
92
+ }
93
+ if (nextSpecial > 0) {
94
+ result.push({ type: 'text', content: remaining.slice(0, nextSpecial) })
95
+ }
96
+ remaining = remaining.slice(nextSpecial)
97
+ }
98
+
99
+ return result
100
+ }
101
+
102
+ export function parseWhatsAppFormatting(text: string): ReactNode[] {
103
+ const tokens = tokenize(text)
104
+
105
+ return tokens.map((token, index) => {
106
+ switch (token.type) {
107
+ case 'bold':
108
+ return <strong key={index}>{token.content}</strong>
109
+ case 'italic':
110
+ return <em key={index}>{token.content}</em>
111
+ case 'strikethrough':
112
+ return <del key={index}>{token.content}</del>
113
+ case 'monospace':
114
+ return <code key={index} className="bg-gray-100 px-1 py-0.5 rounded text-sm">{token.content}</code>
115
+ case 'codeblock':
116
+ return (
117
+ <pre key={index} className="bg-gray-100 p-2 rounded text-sm overflow-x-auto my-1">
118
+ <code>{token.content}</code>
119
+ </pre>
120
+ )
121
+ default:
122
+ return <span key={index}>{token.content}</span>
123
+ }
124
+ })
125
+ }
126
+
127
+ function escapeHtml(text: string): string {
128
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
129
+ }
130
+
131
+ function unescapeHtml(text: string): string {
132
+ return text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ')
133
+ }
134
+
135
+ // Marcador da Private Use Area do Unicode — praticamente impossível de colidir com
136
+ // texto real de mensagem, ao contrário de um sentinela em ASCII (ex: dígitos soltos).
137
+ const CODE_TOKEN_MARK = String.fromCharCode(0xe000)
138
+ const CODE_TOKEN_REGEX = new RegExp(`${CODE_TOKEN_MARK}(\\d+)${CODE_TOKEN_MARK}`, 'g')
139
+
140
+ // waToHTML/htmlToWA formam um par round-trip: todo texto que sai de waToHTML deve
141
+ // reconstruir exatamente o original ao passar por htmlToWA (inclusive blocos de
142
+ // código multi-linha, que exigem distinguir ``` de ` via atributo data-wa).
143
+ export function waToHTML(text: string): string {
144
+ if (!text) return ''
145
+
146
+ const codeTokens: { type: 'block' | 'inline'; content: string }[] = []
147
+ let working = text.replace(/```([\s\S]*?)```/g, (_match, content: string) => {
148
+ codeTokens.push({ type: 'block', content })
149
+ return `${CODE_TOKEN_MARK}${codeTokens.length - 1}${CODE_TOKEN_MARK}`
150
+ })
151
+ working = working.replace(/`([^`\n]+)`/g, (_match, content: string) => {
152
+ codeTokens.push({ type: 'inline', content })
153
+ return `${CODE_TOKEN_MARK}${codeTokens.length - 1}${CODE_TOKEN_MARK}`
154
+ })
155
+
156
+ let html = escapeHtml(working)
157
+ html = html.replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
158
+ html = html.replace(/_([^_\n]+)_/g, '<em>$1</em>')
159
+ html = html.replace(/~([^~\n]+)~/g, '<del>$1</del>')
160
+ html = html.replace(/\n/g, '<br>')
161
+
162
+ html = html.replace(CODE_TOKEN_REGEX, (_match, indexStr: string) => {
163
+ const token = codeTokens[Number(indexStr)]
164
+ const escaped = escapeHtml(token.content)
165
+ if (token.type === 'block') {
166
+ return `<code data-wa="block" class="block bg-black/5 dark:bg-white/10 rounded px-1.5 py-0.5 font-mono text-sm whitespace-pre-wrap">${escaped.replace(/\n/g, '<br>')}</code>`
167
+ }
168
+ return `<code data-wa="inline" class="bg-black/5 dark:bg-white/10 rounded px-0.5 font-mono text-sm">${escaped}</code>`
169
+ })
170
+
171
+ return html
172
+ }
173
+
174
+ export function htmlToWA(html: string): string {
175
+ if (!html) return ''
176
+
177
+ let text = html
178
+ text = text.replace(/<code data-wa="block"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
179
+ `\`\`\`${unescapeHtml(inner.replace(/<br\s*\/?>/gi, '\n'))}\`\`\``
180
+ ))
181
+ text = text.replace(/<code data-wa="inline"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
182
+ `\`${unescapeHtml(inner)}\``
183
+ ))
184
+
185
+ text = text
186
+ .replace(/<br\s*\/?>/gi, '\n')
187
+ .replace(/<div>/gi, '\n')
188
+ .replace(/<\/div>/gi, '')
189
+ .replace(/<\/p>/gi, '\n')
190
+ .replace(/<p[^>]*>/gi, '')
191
+
192
+ text = text.replace(/<strong>(.*?)<\/strong>/gi, '*$1*')
193
+ text = text.replace(/<b>(.*?)<\/b>/gi, '*$1*')
194
+ text = text.replace(/<em>(.*?)<\/em>/gi, '_$1_')
195
+ text = text.replace(/<i>(.*?)<\/i>/gi, '_$1_')
196
+ text = text.replace(/<del>(.*?)<\/del>/gi, '~$1~')
197
+ text = text.replace(/<s>(.*?)<\/s>/gi, '~$1~')
198
+ // Compat: HTML sem os marcadores data-wa (ex: vindo de outro editor) ainda vira código inline.
199
+ text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
200
+
201
+ text = text.replace(/<[^>]+>/g, '')
202
+ text = unescapeHtml(text)
203
+ return text.trim()
204
+ }
205
+
206
+ // Variante inline para contextos de preview (ex: última mensagem numa lista) — sem
207
+ // suporte a bloco de código nem quebras de linha, propositalmente mais simples.
208
+ export function waToHTMLInline(text: string): string {
209
+ if (!text) return ''
210
+ return escapeHtml(text)
211
+ .replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
212
+ .replace(/_([^_\n]+)_/g, '<em>$1</em>')
213
+ .replace(/~([^~\n]+)~/g, '<del>$1</del>')
214
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
215
+ }
@@ -0,0 +1,29 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { ConversationsApi, SSEProvider } from './types'
3
+
4
+ interface ConversationsContextValue {
5
+ api: ConversationsApi
6
+ sse: SSEProvider
7
+ }
8
+
9
+ const ConversationsContext = createContext<ConversationsContextValue | null>(null)
10
+
11
+ export function ConversationsProvider({
12
+ api,
13
+ sse,
14
+ children,
15
+ }: {
16
+ api: ConversationsApi
17
+ sse: SSEProvider
18
+ children: ReactNode
19
+ }) {
20
+ return (
21
+ <ConversationsContext.Provider value={{ api, sse }}>
22
+ {children}
23
+ </ConversationsContext.Provider>
24
+ )
25
+ }
26
+
27
+ export function useConversations(): ConversationsContextValue | null {
28
+ return useContext(ConversationsContext)
29
+ }
@@ -0,0 +1,54 @@
1
+ import type { MessagePayload } from '../types'
2
+
3
+ export interface ConversationsApi {
4
+ fetchMessages(conversationId: string, params?: { limit?: number; before?: string }): Promise<MessagePayload[]>
5
+ fetchConversations(params?: {
6
+ page?: number
7
+ limit?: number
8
+ waitingHuman?: boolean
9
+ search?: string
10
+ }): Promise<ConversationSummary[]>
11
+ sendMessage(conversationId: string, text: string): Promise<MessagePayload>
12
+ sendMedia(
13
+ conversationId: string,
14
+ data: { base64: string; mimeType: string; filename: string; caption?: string },
15
+ ): Promise<MessagePayload>
16
+ sendTemplate(
17
+ conversationId: string,
18
+ data: { templateName: string; languageCode?: string; bodyParams?: string[] },
19
+ ): Promise<void>
20
+ markRead(conversationId: string): Promise<void>
21
+ getContext(conversationId: string): Promise<Record<string, unknown>>
22
+ getDocuments(conversationId: string, params?: { search?: string; page?: number }): Promise<ConversationDocument[]>
23
+ getDocumentUrl(uploadId: string): Promise<string>
24
+ getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
25
+ }
26
+
27
+ export interface SSEProvider {
28
+ connectConversationStream(conversationId: string): EventSource
29
+ connectGlobalStream(): EventSource
30
+ }
31
+
32
+ export interface ConversationSummary {
33
+ id: string
34
+ whatsappNumber: string
35
+ clientName?: string
36
+ lastContent?: string
37
+ lastDirection?: 'inbound' | 'outbound'
38
+ lastAt: string
39
+ lastInboundAt: string | null
40
+ mode: 'bot' | 'human'
41
+ assignedUserId: string | null
42
+ waitingHuman: boolean
43
+ unread: number
44
+ currentState: string
45
+ }
46
+
47
+ export interface ConversationDocument {
48
+ id: string
49
+ filename: string
50
+ mimeType: string
51
+ sizeBytes: number
52
+ source: string
53
+ linkedAt: string
54
+ }