@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,264 @@
1
+ import type { FormEvent } from 'react'
2
+ import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react'
3
+
4
+ export interface WhatsAppTemplateSummary {
5
+ name: string
6
+ language: string
7
+ displayName: string
8
+ shortId: string
9
+ status: string
10
+ bodyText: string | null
11
+ variableCount: number
12
+ }
13
+
14
+ export interface WhatsAppTemplateVariableSuggestion {
15
+ token: string
16
+ label: string
17
+ }
18
+
19
+ export interface WhatsAppTemplateSettingsFormLabels {
20
+ sectionTitle: string
21
+ sectionDescription: string
22
+ templateLabel: string
23
+ refreshList: string
24
+ errorLoading: string
25
+ currentNameSaved: string
26
+ selectPlaceholder: string
27
+ variablesLabel: string
28
+ addVariable: string
29
+ removeVariable: string
30
+ variablesAvailableTitle: string
31
+ variablesAvailableHint: string
32
+ variablesMappingHint: string
33
+ noVariables: string
34
+ save: string
35
+ saving: string
36
+ saveSuccess: string
37
+ }
38
+
39
+ const DEFAULT_LABELS: WhatsAppTemplateSettingsFormLabels = {
40
+ sectionTitle: 'WhatsApp',
41
+ sectionDescription: 'Template usado para reabrir a janela de 24h com o cliente.',
42
+ templateLabel: 'Template aprovado',
43
+ refreshList: 'Atualizar lista',
44
+ errorLoading: 'Não foi possível carregar os templates da Meta.',
45
+ currentNameSaved: 'Nome salvo atualmente:',
46
+ selectPlaceholder: 'Selecione um template…',
47
+ variablesLabel: 'Variáveis do template',
48
+ addVariable: 'Adicionar',
49
+ removeVariable: 'Remover variável',
50
+ variablesAvailableTitle: 'Variáveis disponíveis',
51
+ variablesAvailableHint: 'Clique para inserir na próxima variável vazia.',
52
+ variablesMappingHint: 'A ordem das variáveis abaixo deve corresponder à ordem {{1}}, {{2}}... do template.',
53
+ noVariables: 'Este template não tem variáveis.',
54
+ save: 'Salvar',
55
+ saving: 'Salvando...',
56
+ saveSuccess: 'Configuração salva com sucesso.',
57
+ }
58
+
59
+ export interface WhatsAppTemplateSettingsFormProps {
60
+ templates: WhatsAppTemplateSummary[]
61
+ loadingTemplates?: boolean
62
+ templatesError?: boolean
63
+ onRefreshTemplates?: () => void
64
+ selectedTemplateName: string
65
+ onSelectTemplate: (name: string, template: WhatsAppTemplateSummary | undefined) => void
66
+ variables: string[]
67
+ onVariablesChange: (variables: string[]) => void
68
+ availableVariables?: WhatsAppTemplateVariableSuggestion[]
69
+ saving?: boolean
70
+ saveSuccess?: boolean
71
+ onSave: (event: FormEvent) => void
72
+ labels?: Partial<WhatsAppTemplateSettingsFormLabels>
73
+ }
74
+
75
+ // Paridade com financiamento-imobiliario-bot/apps/web/src/pages/MessagesPage.tsx (aba
76
+ // "templates", seção WhatsApp) — puramente apresentacional: o host busca os templates da
77
+ // Meta e persiste a seleção via seu próprio ConversationsApi/backend, injetando aqui só
78
+ // dados e callbacks. Nenhuma chamada de rede acontece dentro deste componente.
79
+ export function WhatsAppTemplateSettingsForm({
80
+ templates,
81
+ loadingTemplates = false,
82
+ templatesError = false,
83
+ onRefreshTemplates,
84
+ selectedTemplateName,
85
+ onSelectTemplate,
86
+ variables,
87
+ onVariablesChange,
88
+ availableVariables = [],
89
+ saving = false,
90
+ saveSuccess = false,
91
+ onSave,
92
+ labels: labelsOverride,
93
+ }: WhatsAppTemplateSettingsFormProps) {
94
+ const labels = { ...DEFAULT_LABELS, ...labelsOverride }
95
+ const selected = templates.find((template) => template.name === selectedTemplateName)
96
+
97
+ function handleSelect(name: string) {
98
+ const template = templates.find((t) => t.name === name)
99
+ onSelectTemplate(name, template)
100
+ }
101
+
102
+ function updateVariable(index: number, value: string) {
103
+ const next = [...variables]
104
+ next[index] = value
105
+ onVariablesChange(next)
106
+ }
107
+
108
+ function removeVariable(index: number) {
109
+ onVariablesChange(variables.filter((_, i) => i !== index))
110
+ }
111
+
112
+ function insertVariable(token: string) {
113
+ const emptyIndex = variables.findIndex((value) => !value.trim())
114
+ if (emptyIndex >= 0) {
115
+ const next = [...variables]
116
+ next[emptyIndex] = token
117
+ onVariablesChange(next)
118
+ return
119
+ }
120
+ onVariablesChange([...variables, token])
121
+ }
122
+
123
+ return (
124
+ <section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
125
+ <div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
126
+ <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.sectionTitle}</h2>
127
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{labels.sectionDescription}</p>
128
+ </div>
129
+
130
+ <form onSubmit={onSave} className="p-6 space-y-4">
131
+ <div>
132
+ <div className="flex items-center justify-between mb-1.5">
133
+ <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{labels.templateLabel}</label>
134
+ <button
135
+ type="button"
136
+ onClick={onRefreshTemplates}
137
+ disabled={loadingTemplates}
138
+ className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50"
139
+ >
140
+ <RefreshCw size={11} className={loadingTemplates ? 'animate-spin' : ''} />
141
+ {labels.refreshList}
142
+ </button>
143
+ </div>
144
+
145
+ {templatesError && (
146
+ <div className="mb-2 rounded-lg bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
147
+ {labels.errorLoading}
148
+ <br />
149
+ <span className="text-gray-500 dark:text-gray-400 mt-0.5 block">
150
+ {labels.currentNameSaved} <strong>{selectedTemplateName || '—'}</strong>
151
+ </span>
152
+ </div>
153
+ )}
154
+
155
+ {loadingTemplates && <div className="h-10 rounded-xl bg-gray-100 dark:bg-gray-700 animate-pulse" />}
156
+
157
+ {!loadingTemplates && !templatesError && (
158
+ <select
159
+ value={selectedTemplateName}
160
+ onChange={(e) => handleSelect(e.target.value)}
161
+ className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all"
162
+ >
163
+ <option value="">{labels.selectPlaceholder}</option>
164
+ {templates.map((template) => (
165
+ <option key={`${template.name}-${template.language}`} value={template.name} disabled={template.status !== 'APPROVED'}>
166
+ {template.displayName} · {template.shortId} ({template.language}) {template.status !== 'APPROVED' ? `[${template.status}]` : ''}
167
+ </option>
168
+ ))}
169
+ </select>
170
+ )}
171
+
172
+ {selected?.bodyText && (
173
+ <div className="mt-2 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 px-3 py-2 text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
174
+ {selected.bodyText}
175
+ </div>
176
+ )}
177
+ </div>
178
+
179
+ <div>
180
+ <div className="flex items-center justify-between mb-1.5">
181
+ <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{labels.variablesLabel}</label>
182
+ <button
183
+ type="button"
184
+ onClick={() => onVariablesChange([...variables, ''])}
185
+ className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
186
+ >
187
+ <Plus size={12} />
188
+ {labels.addVariable}
189
+ </button>
190
+ </div>
191
+
192
+ {availableVariables.length > 0 && (
193
+ <div className="mb-3 rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/40 p-2.5">
194
+ <p className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1.5">{labels.variablesAvailableTitle}</p>
195
+ <div className="flex flex-wrap gap-1.5">
196
+ {availableVariables.map((available) => (
197
+ <button
198
+ key={available.token}
199
+ type="button"
200
+ onClick={() => insertVariable(available.token)}
201
+ title={`Inserir ${available.token}`}
202
+ className="inline-flex items-center gap-1 rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs hover:border-blue-400 dark:hover:border-blue-500 transition-colors"
203
+ >
204
+ <code className="text-blue-600 dark:text-blue-300">{available.token}</code>
205
+ <span className="text-gray-400 dark:text-gray-500">· {available.label}</span>
206
+ </button>
207
+ ))}
208
+ </div>
209
+ <p className="mt-1.5 text-[11px] text-gray-400 dark:text-gray-500">{labels.variablesAvailableHint}</p>
210
+ </div>
211
+ )}
212
+
213
+ <p className="text-xs text-gray-400 dark:text-gray-500 mb-2">{labels.variablesMappingHint}</p>
214
+
215
+ {variables.length === 0 && (
216
+ <p className="text-xs text-gray-400 dark:text-gray-500 italic">{labels.noVariables}</p>
217
+ )}
218
+
219
+ <div className="space-y-2">
220
+ {variables.map((variable, index) => (
221
+ <div key={index} className="flex items-center gap-2">
222
+ <span className="text-xs text-gray-400 dark:text-gray-500 w-6 text-right flex-shrink-0">
223
+ {`{{${index + 1}}}`}
224
+ </span>
225
+ <input
226
+ type="text"
227
+ value={variable}
228
+ onChange={(e) => updateVariable(index, e.target.value)}
229
+ placeholder="Ex: {clientName}"
230
+ className="flex-1 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all placeholder:text-gray-400 dark:placeholder:text-gray-500"
231
+ />
232
+ <button
233
+ type="button"
234
+ onClick={() => removeVariable(index)}
235
+ className="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors flex-shrink-0"
236
+ title={labels.removeVariable}
237
+ >
238
+ <Trash2 size={14} />
239
+ </button>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ </div>
244
+
245
+ {saveSuccess && (
246
+ <div className="bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-800 rounded-xl px-4 py-3 text-sm text-green-700 dark:text-green-400">
247
+ {labels.saveSuccess}
248
+ </div>
249
+ )}
250
+
251
+ <div className="flex justify-end">
252
+ <button
253
+ type="submit"
254
+ disabled={saving || !selectedTemplateName.trim()}
255
+ className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
256
+ >
257
+ {saving ? <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" /> : <Save size={14} />}
258
+ {saving ? labels.saving : labels.save}
259
+ </button>
260
+ </div>
261
+ </form>
262
+ </section>
263
+ )
264
+ }
package/src/styles.css ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Wallpaper de fundo da conversa — padrão de doodles do WhatsApp.
3
+ * Extraído 1:1 de financiamento-imobiliario-bot/apps/web/src/index.css:119-129
4
+ * (mesmas cores, mesmo SVG, mesma opacidade) para preservar paridade visual.
5
+ *
6
+ * Consumo: import '@adatechnology/conversations-ui/styles.css' uma vez no host.
7
+ * Alterna claro/escuro via ancestral `.dark` (mesma convenção do darkMode: 'class'
8
+ * do Tailwind) — não depende de nenhuma configuração do Tailwind do host.
9
+ */
10
+
11
+ .cv-wallpaper {
12
+ background-color: #efeae2;
13
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23d7cfc0' stroke-width='1.2' opacity='0.7'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
14
+ background-repeat: repeat;
15
+ background-size: 100px 100px;
16
+ }
17
+
18
+ .dark .cv-wallpaper {
19
+ background-color: #0b141a;
20
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%2319232a' stroke-width='1.2' opacity='0.9'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
21
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { ConversationsTheme } from './types'
2
+
3
+ const themeCache = new Map<string, string>()
4
+
5
+ export function getThemeClass(defaultClass: string, themeKey?: string, theme?: ConversationsTheme): string {
6
+ if (!themeKey || !theme) return defaultClass
7
+
8
+ const cacheKey = `${defaultClass}:${themeKey}`
9
+ if (themeCache.has(cacheKey)) return themeCache.get(cacheKey)!
10
+
11
+ const value = (theme as Record<string, string | undefined>)[themeKey]
12
+ const result = value ?? defaultClass
13
+ themeCache.set(cacheKey, result)
14
+ return result
15
+ }
16
+
17
+ export function getCustomStyles(theme?: ConversationsTheme): Record<string, string> {
18
+ if (!theme) return {}
19
+
20
+ const styles: Record<string, string> = {}
21
+
22
+ if (theme.primaryColor) styles['--cv-primary'] = theme.primaryColor
23
+ if (theme.backgroundColor) styles['--cv-bg'] = theme.backgroundColor
24
+ if (theme.bubbleSent) styles['--cv-bubble-sent'] = theme.bubbleSent
25
+ if (theme.bubbleReceived) styles['--cv-bubble-received'] = theme.bubbleReceived
26
+ if (theme.textPrimary) styles['--cv-text-primary'] = theme.textPrimary
27
+ if (theme.textSecondary) styles['--cv-text-secondary'] = theme.textSecondary
28
+
29
+ return styles
30
+ }
package/src/types.ts ADDED
@@ -0,0 +1,44 @@
1
+ export interface ConversationsUIConfig {
2
+ apiBaseUrl: string
3
+ theme?: ConversationsTheme
4
+ features?: ConversationsFeatures
5
+ }
6
+
7
+ export interface ConversationsTheme {
8
+ primaryColor?: string
9
+ backgroundColor?: string
10
+ bubbleSent?: string
11
+ bubbleReceived?: string
12
+ textPrimary?: string
13
+ textSecondary?: string
14
+ }
15
+
16
+ export interface ConversationsFeatures {
17
+ audio?: boolean
18
+ documents?: boolean
19
+ emoji?: boolean
20
+ darkMode?: boolean
21
+ }
22
+
23
+ export interface MessagePayload {
24
+ id: string
25
+ type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template'
26
+ content?: string
27
+ caption?: string
28
+ mediaUrl?: string
29
+ base64?: string
30
+ uploadId?: string
31
+ mediaId?: string
32
+ mimeType?: string
33
+ filename?: string
34
+ sizeBytes?: number
35
+ direction: 'inbound' | 'outbound'
36
+ sender: 'bot' | 'customer' | 'agent'
37
+ timestamp: string
38
+ status?: 'sent' | 'delivered' | 'read' | 'failed'
39
+ readAt?: string
40
+ agentName?: string | null
41
+ templateName?: string
42
+ isFirstInGroup?: boolean
43
+ isLastInGroup?: boolean
44
+ }
@@ -0,0 +1,48 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+
3
+ const STORAGE_KEY = 'conversations-ui-dark-mode'
4
+
5
+ function getInitialDark(): boolean {
6
+ if (typeof window === 'undefined') return false
7
+
8
+ const stored = localStorage.getItem(STORAGE_KEY)
9
+ if (stored !== null) {
10
+ return stored === 'true'
11
+ }
12
+
13
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
14
+ }
15
+
16
+ export function useDarkMode(): { isDark: boolean; toggle: () => void } {
17
+ const [isDark, setIsDark] = useState(getInitialDark)
18
+
19
+ useEffect(() => {
20
+ const root = document.documentElement
21
+ if (isDark) {
22
+ root.classList.add('dark')
23
+ } else {
24
+ root.classList.remove('dark')
25
+ }
26
+ localStorage.setItem(STORAGE_KEY, String(isDark))
27
+ }, [isDark])
28
+
29
+ useEffect(() => {
30
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
31
+
32
+ const handleChange = (event: MediaQueryListEvent) => {
33
+ const stored = localStorage.getItem(STORAGE_KEY)
34
+ if (stored === null) {
35
+ setIsDark(event.matches)
36
+ }
37
+ }
38
+
39
+ mediaQuery.addEventListener('change', handleChange)
40
+ return () => mediaQuery.removeEventListener('change', handleChange)
41
+ }, [])
42
+
43
+ const toggle = useCallback(() => {
44
+ setIsDark((prev) => !prev)
45
+ }, [])
46
+
47
+ return { isDark, toggle }
48
+ }
@@ -0,0 +1,64 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { useConversations } from './providers/ConversationsProvider'
3
+ import type { ConversationSummary } from './providers/types'
4
+
5
+ interface UseWaitingNotificationsResult {
6
+ unreadCount: number
7
+ conversations: ConversationSummary[]
8
+ }
9
+
10
+ export function useWaitingNotifications(): UseWaitingNotificationsResult {
11
+ const conversationsCtx = useConversations()
12
+ const [conversations, setConversations] = useState<ConversationSummary[]>([])
13
+ const previousUnreadMap = useRef<Map<string, number>>(new Map())
14
+ const notifiedIds = useRef<Set<string>>(new Set())
15
+
16
+ const pollConversations = useCallback(async () => {
17
+ if (!conversationsCtx) return
18
+
19
+ try {
20
+ const result = await conversationsCtx.api.fetchConversations({ limit: 50 })
21
+ setConversations(result)
22
+
23
+ const currentMap = new Map<string, number>()
24
+ for (const conv of result) {
25
+ currentMap.set(conv.id, conv.unread)
26
+ }
27
+
28
+ for (const conv of result) {
29
+ if (conv.unread <= 0) continue
30
+
31
+ const prev = previousUnreadMap.current.get(conv.id) ?? 0
32
+
33
+ if (conv.unread > prev && !notifiedIds.current.has(conv.id)) {
34
+ notifiedIds.current.add(conv.id)
35
+
36
+ if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
37
+ const title = conv.clientName ?? conv.whatsappNumber
38
+ const body = conv.lastContent ?? 'Nova mensagem'
39
+ new Notification(title, { body, icon: '/favicon.ico' })
40
+ }
41
+ }
42
+ }
43
+
44
+ previousUnreadMap.current = currentMap
45
+ } catch {
46
+ // Silently ignore polling errors
47
+ }
48
+ }, [conversationsCtx])
49
+
50
+ useEffect(() => {
51
+ if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'default') {
52
+ Notification.requestPermission()
53
+ }
54
+
55
+ pollConversations()
56
+
57
+ const interval = setInterval(pollConversations, 10_000)
58
+ return () => clearInterval(interval)
59
+ }, [pollConversations])
60
+
61
+ const unreadCount = conversations.reduce((sum, conv) => sum + conv.unread, 0)
62
+
63
+ return { unreadCount, conversations }
64
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020", "DOM"],
6
+ "moduleResolution": "bundler",
7
+ "declaration": true,
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "jsx": "react-jsx"
13
+ },
14
+ "include": ["src"]
15
+ }