@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.
- package/dist/channel/index.d.ts +2 -0
- package/dist/channel/index.js +0 -0
- package/dist/chunk-ZDURDZTM.js +199 -0
- package/dist/flows/index.d.ts +246 -0
- package/dist/flows/index.js +1110 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +2077 -0
- package/dist/styles.css +11 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +52 -0
- package/src/AudioPlayer.tsx +103 -0
- package/src/Avatar.tsx +63 -0
- package/src/ConversationListItem.tsx +147 -0
- package/src/ConversationLocalesProvider.tsx +76 -0
- package/src/DateDivider.tsx +32 -0
- package/src/EmojiPicker.tsx +77 -0
- package/src/FileIcon.tsx +33 -0
- package/src/Lightbox.tsx +17 -0
- package/src/MediaRenderer.tsx +158 -0
- package/src/MessageBubble.tsx +129 -0
- package/src/MessageComposer.tsx +211 -0
- package/src/MessageTail.tsx +18 -0
- package/src/MessageText.tsx +42 -0
- package/src/MessageTimestamp.tsx +22 -0
- package/src/SimpleEmojiPicker.tsx +72 -0
- package/src/StatusTicks.tsx +39 -0
- package/src/Toast.tsx +140 -0
- package/src/Wallpaper.tsx +13 -0
- package/src/WhatsAppMessageEditor.tsx +142 -0
- package/src/channel/index.ts +1 -0
- package/src/conversations/index.ts +1 -0
- package/src/flows/FlowGroupFrame.tsx +21 -0
- package/src/flows/FlowGroupHeader.tsx +31 -0
- package/src/flows/FlowMapCanvas.tsx +76 -0
- package/src/flows/FlowMapNode.tsx +39 -0
- package/src/flows/FlowNodeCard.tsx +150 -0
- package/src/flows/FlowNodePanel.tsx +356 -0
- package/src/flows/FlowPalette.tsx +137 -0
- package/src/flows/FlowPortalNode.tsx +30 -0
- package/src/flows/FlowWhatsAppPreview.tsx +67 -0
- package/src/flows/flowGraph.ts +391 -0
- package/src/flows/index.ts +55 -0
- package/src/flows/labels.ts +187 -0
- package/src/hooks/useAsyncResource.ts +38 -0
- package/src/hooks/useConversationContext.ts +23 -0
- package/src/hooks/useConversationDocuments.ts +33 -0
- package/src/hooks/useConversationList.ts +32 -0
- package/src/hooks/useConversationMessages.ts +64 -0
- package/src/hooks/useConversationRealtime.ts +50 -0
- package/src/index.ts +87 -0
- package/src/lib/format.ts +32 -0
- package/src/lib/phone.ts +26 -0
- package/src/lib/whatsapp-formatting.tsx +215 -0
- package/src/providers/ConversationsProvider.tsx +29 -0
- package/src/providers/types.ts +54 -0
- package/src/settings/TopicsForm.tsx +109 -0
- package/src/settings/WelcomeFarewellForm.tsx +118 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
- package/src/styles.css +21 -0
- package/src/theme.ts +30 -0
- package/src/types.ts +44 -0
- package/src/useDarkMode.ts +48 -0
- package/src/useWaitingNotifications.ts +64 -0
- 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
|
+
}
|