novalab-admin-noti-sdk 0.1.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/package.json +23 -0
- package/src/AdminNotificationsPanel.jsx +603 -0
- package/src/createAdminNotiApi.js +56 -0
- package/src/index.js +6 -0
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "novalab-admin-noti-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nhúng UI quản lý push notification (client admin) vào ứng dụng React",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@tanstack/react-query": "^5.0.0",
|
|
15
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
16
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"axios": "^1.13.5",
|
|
20
|
+
"lucide-react": "^0.575.0"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false
|
|
23
|
+
}
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, useState } from 'react';
|
|
2
|
+
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Bell, Radio, FileText, ToggleLeft, ToggleRight, Plus, Pencil } from 'lucide-react';
|
|
4
|
+
import { createAdminNotiApi } from './createAdminNotiApi.js';
|
|
5
|
+
|
|
6
|
+
const QK = {
|
|
7
|
+
channels: ['@gamifyengine/admin-noti', 'channels'],
|
|
8
|
+
templates: ['@gamifyengine/admin-noti', 'notification-templates'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const AdminNotiContext = createContext(null);
|
|
12
|
+
|
|
13
|
+
function useAdminNoti() {
|
|
14
|
+
const ctx = useContext(AdminNotiContext);
|
|
15
|
+
if (!ctx) throw new Error('AdminNotificationsPanel must be used inside AdminNotiSdkProvider');
|
|
16
|
+
return ctx;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TYPES = ['info', 'reward', 'promo', 'system'];
|
|
20
|
+
const TYPE_COLORS = {
|
|
21
|
+
info: 'bg-cyan-900/40 text-cyan-400 border-cyan-800',
|
|
22
|
+
reward: 'bg-yellow-900/40 text-yellow-400 border-yellow-800',
|
|
23
|
+
promo: 'bg-pink-900/40 text-pink-400 border-pink-800',
|
|
24
|
+
system: 'bg-purple-900/40 text-purple-400 border-purple-800',
|
|
25
|
+
};
|
|
26
|
+
const TITLE_MAX = 60;
|
|
27
|
+
const MSG_MAX = 200;
|
|
28
|
+
|
|
29
|
+
function PreviewCard({ title, message, type }) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4">
|
|
32
|
+
<p className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-gray-500">Preview</p>
|
|
33
|
+
<div
|
|
34
|
+
className={`flex gap-3 rounded-lg border p-3 ${TYPE_COLORS[type] ?? TYPE_COLORS.info}`}
|
|
35
|
+
>
|
|
36
|
+
<Bell className="mt-0.5 h-5 w-5 shrink-0" />
|
|
37
|
+
<div className="min-w-0">
|
|
38
|
+
<p className="truncate text-sm font-semibold">
|
|
39
|
+
{title || <span className="opacity-40">Title…</span>}
|
|
40
|
+
</p>
|
|
41
|
+
{message && <p className="mt-0.5 line-clamp-2 text-xs opacity-70">{message}</p>}
|
|
42
|
+
<p className="mt-1 text-[10px] opacity-40">just now</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function SendTab() {
|
|
50
|
+
const { adminApi, onToast } = useAdminNoti();
|
|
51
|
+
const [title, setTitle] = useState('');
|
|
52
|
+
const [message, setMessage] = useState('');
|
|
53
|
+
const [type, setType] = useState('info');
|
|
54
|
+
const [target, setTarget] = useState('all');
|
|
55
|
+
const [targetUserId, setTargetUserId] = useState('');
|
|
56
|
+
const [sending, setSending] = useState(false);
|
|
57
|
+
|
|
58
|
+
const titleValid = title.trim().length > 0 && title.length <= TITLE_MAX;
|
|
59
|
+
const msgValid = message.trim().length > 0 && message.length <= MSG_MAX;
|
|
60
|
+
const canSubmit = titleValid && msgValid && !sending;
|
|
61
|
+
|
|
62
|
+
async function handleSubmit(e) {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
if (!canSubmit) return;
|
|
65
|
+
setSending(true);
|
|
66
|
+
try {
|
|
67
|
+
const body = {
|
|
68
|
+
title: title.trim(),
|
|
69
|
+
message: message.trim(),
|
|
70
|
+
type,
|
|
71
|
+
...(target === 'user' && targetUserId.trim() ? { userId: targetUserId.trim() } : {}),
|
|
72
|
+
};
|
|
73
|
+
const res = await adminApi.notifications.push(body);
|
|
74
|
+
const sent = res?.data?.sent ?? 1;
|
|
75
|
+
onToast({
|
|
76
|
+
title: 'Notification sent',
|
|
77
|
+
message: `Delivered to ${sent} user${sent !== 1 ? 's' : ''}.`,
|
|
78
|
+
variant: 'success',
|
|
79
|
+
});
|
|
80
|
+
setTitle('');
|
|
81
|
+
setMessage('');
|
|
82
|
+
setType('info');
|
|
83
|
+
setTarget('all');
|
|
84
|
+
setTargetUserId('');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
onToast({
|
|
87
|
+
title: 'Send failed',
|
|
88
|
+
message: err?.message || 'Unknown error',
|
|
89
|
+
variant: 'error',
|
|
90
|
+
});
|
|
91
|
+
} finally {
|
|
92
|
+
setSending(false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<form onSubmit={handleSubmit} noValidate className="max-w-2xl space-y-5">
|
|
98
|
+
<div>
|
|
99
|
+
<div className="mb-1.5 flex items-center justify-between">
|
|
100
|
+
<label className="text-xs font-semibold uppercase tracking-wider text-gray-400">Title</label>
|
|
101
|
+
<span className={`text-xs ${title.length > TITLE_MAX ? 'text-red-400' : 'text-gray-600'}`}>
|
|
102
|
+
{title.length}/{TITLE_MAX}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
<input
|
|
106
|
+
type="text"
|
|
107
|
+
value={title}
|
|
108
|
+
maxLength={TITLE_MAX}
|
|
109
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
110
|
+
placeholder="Notification title"
|
|
111
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
112
|
+
required
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div>
|
|
117
|
+
<div className="mb-1.5 flex items-center justify-between">
|
|
118
|
+
<label className="text-xs font-semibold uppercase tracking-wider text-gray-400">Message</label>
|
|
119
|
+
<span className={`text-xs ${message.length > MSG_MAX ? 'text-red-400' : 'text-gray-600'}`}>
|
|
120
|
+
{message.length}/{MSG_MAX}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
<textarea
|
|
124
|
+
value={message}
|
|
125
|
+
maxLength={MSG_MAX}
|
|
126
|
+
rows={3}
|
|
127
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
128
|
+
placeholder="Notification message body"
|
|
129
|
+
className="w-full resize-y rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
130
|
+
required
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div>
|
|
135
|
+
<label className="mb-2 block text-xs font-semibold uppercase tracking-wider text-gray-400">
|
|
136
|
+
Type
|
|
137
|
+
</label>
|
|
138
|
+
<div className="flex flex-wrap gap-2">
|
|
139
|
+
{TYPES.map((t) => (
|
|
140
|
+
<button
|
|
141
|
+
key={t}
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={() => setType(t)}
|
|
144
|
+
className={`rounded-full border px-3 py-1.5 text-xs font-semibold capitalize transition-all ${
|
|
145
|
+
type === t
|
|
146
|
+
? TYPE_COLORS[t]
|
|
147
|
+
: 'border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600 hover:text-gray-300'
|
|
148
|
+
}`}
|
|
149
|
+
>
|
|
150
|
+
{t}
|
|
151
|
+
</button>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div>
|
|
157
|
+
<label className="mb-2 block text-xs font-semibold uppercase tracking-wider text-gray-400">
|
|
158
|
+
Target
|
|
159
|
+
</label>
|
|
160
|
+
<div className="mb-2 flex gap-4">
|
|
161
|
+
{[
|
|
162
|
+
{ value: 'all', label: 'All users' },
|
|
163
|
+
{ value: 'user', label: 'Specific user' },
|
|
164
|
+
].map((opt) => (
|
|
165
|
+
<label key={opt.value} className="flex cursor-pointer items-center gap-2 text-sm text-gray-300">
|
|
166
|
+
<input
|
|
167
|
+
type="radio"
|
|
168
|
+
name="target"
|
|
169
|
+
value={opt.value}
|
|
170
|
+
checked={target === opt.value}
|
|
171
|
+
onChange={() => setTarget(opt.value)}
|
|
172
|
+
className="accent-indigo-500"
|
|
173
|
+
/>
|
|
174
|
+
{opt.label}
|
|
175
|
+
</label>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
{target === 'user' && (
|
|
179
|
+
<input
|
|
180
|
+
type="text"
|
|
181
|
+
value={targetUserId}
|
|
182
|
+
onChange={(e) => setTargetUserId(e.target.value)}
|
|
183
|
+
placeholder="User ID (UUID)"
|
|
184
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<PreviewCard title={title} message={message} type={type} />
|
|
190
|
+
|
|
191
|
+
<button
|
|
192
|
+
type="submit"
|
|
193
|
+
disabled={!canSubmit}
|
|
194
|
+
className="flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
195
|
+
>
|
|
196
|
+
<Bell className="h-4 w-4" />
|
|
197
|
+
{sending ? 'Sending…' : 'Send notification'}
|
|
198
|
+
</button>
|
|
199
|
+
</form>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const CHANNEL_LABELS = {
|
|
204
|
+
in_app: 'In-App',
|
|
205
|
+
email: 'Email',
|
|
206
|
+
sms: 'SMS',
|
|
207
|
+
push: 'Push',
|
|
208
|
+
webhook: 'Webhook',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const AVAILABLE_CHANNELS = ['in_app'];
|
|
212
|
+
|
|
213
|
+
function ChannelsTab() {
|
|
214
|
+
const { adminApi, onToast } = useAdminNoti();
|
|
215
|
+
const queryClient = useQueryClient();
|
|
216
|
+
|
|
217
|
+
const { data: channelsResponse, isLoading } = useQuery({
|
|
218
|
+
queryKey: QK.channels,
|
|
219
|
+
queryFn: () => adminApi.channels.list(),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const channels = channelsResponse?.data ?? [];
|
|
223
|
+
|
|
224
|
+
const toggleMutation = useMutation({
|
|
225
|
+
mutationFn: ({ id, isEnabled, channelType }) =>
|
|
226
|
+
id
|
|
227
|
+
? adminApi.channels.update(id, { isEnabled })
|
|
228
|
+
: adminApi.channels.create({ channelType, isEnabled }),
|
|
229
|
+
onSuccess: (res, vars) => {
|
|
230
|
+
queryClient.setQueryData(QK.channels, (old) => {
|
|
231
|
+
if (!old) return old;
|
|
232
|
+
const updatedConfig = res?.data ?? res;
|
|
233
|
+
const list = old?.data ?? [];
|
|
234
|
+
const exists = list.find((c) => c.channelType === vars.channelType);
|
|
235
|
+
const newList = exists
|
|
236
|
+
? list.map((c) =>
|
|
237
|
+
c.channelType === vars.channelType
|
|
238
|
+
? { ...c, ...updatedConfig, isEnabled: vars.isEnabled }
|
|
239
|
+
: c
|
|
240
|
+
)
|
|
241
|
+
: [...list, { ...updatedConfig, isEnabled: vars.isEnabled }];
|
|
242
|
+
return { ...old, data: newList };
|
|
243
|
+
});
|
|
244
|
+
queryClient.invalidateQueries({ queryKey: QK.channels });
|
|
245
|
+
onToast({
|
|
246
|
+
title: 'Channel updated',
|
|
247
|
+
message: `${CHANNEL_LABELS[vars.channelType]} channel ${vars.isEnabled ? 'enabled' : 'disabled'}.`,
|
|
248
|
+
variant: 'success',
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
onError: (err) => {
|
|
252
|
+
onToast({
|
|
253
|
+
title: 'Update failed',
|
|
254
|
+
message: err?.message || 'Error',
|
|
255
|
+
variant: 'error',
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function handleToggle(channelType) {
|
|
261
|
+
const existing = channels.find((c) => c.channelType === channelType);
|
|
262
|
+
const currentEnabled = existing?.isEnabled ?? false;
|
|
263
|
+
toggleMutation.mutate({
|
|
264
|
+
id: existing?.id,
|
|
265
|
+
channelType,
|
|
266
|
+
isEnabled: !currentEnabled,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (isLoading) {
|
|
271
|
+
return <p className="text-sm text-gray-500">Loading channels…</p>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className="max-w-2xl space-y-3">
|
|
276
|
+
<p className="mb-4 text-xs text-gray-500">
|
|
277
|
+
Enable channels to allow the system to deliver automatic notifications (points earned, tier
|
|
278
|
+
upgrades, achievements, etc.) to users.
|
|
279
|
+
</p>
|
|
280
|
+
{AVAILABLE_CHANNELS.map((channelType) => {
|
|
281
|
+
const existing = channels.find((c) => c.channelType === channelType);
|
|
282
|
+
const isEnabled = existing?.isEnabled ?? false;
|
|
283
|
+
const isSaving = toggleMutation.isPending && toggleMutation.variables?.channelType === channelType;
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div
|
|
287
|
+
key={channelType}
|
|
288
|
+
className="flex items-center justify-between rounded-xl border border-gray-700 bg-gray-800/60 px-4 py-3"
|
|
289
|
+
>
|
|
290
|
+
<div>
|
|
291
|
+
<p className="text-sm font-semibold text-white">{CHANNEL_LABELS[channelType]}</p>
|
|
292
|
+
<p className="mt-0.5 text-xs text-gray-500">
|
|
293
|
+
{isEnabled ? 'Active — notifications will be delivered' : 'Disabled'}
|
|
294
|
+
</p>
|
|
295
|
+
</div>
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
disabled={isSaving}
|
|
299
|
+
onClick={() => handleToggle(channelType)}
|
|
300
|
+
className="transition-opacity disabled:opacity-50"
|
|
301
|
+
title={isEnabled ? 'Disable' : 'Enable'}
|
|
302
|
+
>
|
|
303
|
+
{isEnabled ? (
|
|
304
|
+
<ToggleRight className="h-8 w-8 text-indigo-400" />
|
|
305
|
+
) : (
|
|
306
|
+
<ToggleLeft className="h-8 w-8 text-gray-600" />
|
|
307
|
+
)}
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
})}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const AUTO_EVENT_TYPES = [
|
|
317
|
+
{ type: 'points_earned', label: 'Points earned', vars: ['{{points}}', '{{currency}}', '{{balance}}'] },
|
|
318
|
+
{ type: 'tier_upgrade', label: 'Tier upgrade', vars: ['{{tier_name}}', '{{points}}'] },
|
|
319
|
+
{ type: 'tier_downgrade', label: 'Tier downgrade', vars: ['{{tier_name}}', '{{points}}'] },
|
|
320
|
+
{ type: 'achievement_unlocked', label: 'Achievement unlocked', vars: ['{{achievement_name}}', '{{points}}'] },
|
|
321
|
+
{ type: 'reward_redeemed', label: 'Reward redeemed', vars: ['{{reward_name}}', '{{points_cost}}'] },
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
const TEMPLATE_DEFAULTS = {
|
|
325
|
+
points_earned: { title: 'Points earned!', body: 'You earned {{points}} {{currency}}. Balance: {{balance}}.' },
|
|
326
|
+
tier_upgrade: { title: 'Tier upgrade!', body: 'You reached {{tier_name}}!' },
|
|
327
|
+
tier_downgrade: { title: 'Tier update', body: 'Your tier changed to {{tier_name}}.' },
|
|
328
|
+
achievement_unlocked: { title: 'Achievement unlocked!', body: '{{achievement_name}} — {{points}} pts.' },
|
|
329
|
+
reward_redeemed: { title: 'Reward redeemed', body: '{{reward_name}} redeemed for {{points_cost}} pts.' },
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
function TemplateRow({ eventDef, existing, onSave }) {
|
|
333
|
+
const [open, setOpen] = useState(false);
|
|
334
|
+
const [tplTitle, setTplTitle] = useState('');
|
|
335
|
+
const [body, setBody] = useState('');
|
|
336
|
+
|
|
337
|
+
function openEdit() {
|
|
338
|
+
const tpl = existing?.channels?.in_app;
|
|
339
|
+
setTplTitle(tpl?.title ?? TEMPLATE_DEFAULTS[eventDef.type]?.title ?? '');
|
|
340
|
+
setBody(tpl?.body ?? tpl?.message ?? TEMPLATE_DEFAULTS[eventDef.type]?.body ?? '');
|
|
341
|
+
setOpen(true);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function handleSave() {
|
|
345
|
+
onSave({ notificationType: eventDef.type, id: existing?.id, title: tplTitle.trim(), body: body.trim() });
|
|
346
|
+
setOpen(false);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const isActive = existing?.isActive !== false && existing;
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4">
|
|
353
|
+
<div className="flex items-center justify-between">
|
|
354
|
+
<div>
|
|
355
|
+
<p className="text-sm font-semibold text-white">{eventDef.label}</p>
|
|
356
|
+
<p className="mt-0.5 font-mono text-[10px] text-gray-500">{eventDef.type}</p>
|
|
357
|
+
{isActive && (
|
|
358
|
+
<p className="mt-1 max-w-xs truncate text-xs text-gray-400">
|
|
359
|
+
{existing.channels?.in_app?.title || existing.channels?.in_app?.body || '—'}
|
|
360
|
+
</p>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
<div className="flex items-center gap-2">
|
|
364
|
+
<span
|
|
365
|
+
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold ${
|
|
366
|
+
isActive
|
|
367
|
+
? 'border-green-800 bg-green-900/40 text-green-400'
|
|
368
|
+
: 'border-gray-700 bg-gray-800 text-gray-500'
|
|
369
|
+
}`}
|
|
370
|
+
>
|
|
371
|
+
{isActive ? 'active' : 'not set'}
|
|
372
|
+
</span>
|
|
373
|
+
<button
|
|
374
|
+
type="button"
|
|
375
|
+
onClick={openEdit}
|
|
376
|
+
className="rounded-lg bg-gray-700 p-1.5 text-gray-300 transition-colors hover:bg-gray-600"
|
|
377
|
+
title="Edit template"
|
|
378
|
+
>
|
|
379
|
+
{existing ? <Pencil className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
|
380
|
+
</button>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{open && (
|
|
385
|
+
<div className="mt-4 space-y-3 border-t border-gray-700 pt-4">
|
|
386
|
+
<div className="mb-1 flex flex-wrap gap-1">
|
|
387
|
+
{eventDef.vars.map((v) => (
|
|
388
|
+
<span key={v} className="rounded bg-gray-700 px-1.5 py-0.5 font-mono text-[10px] text-gray-300">
|
|
389
|
+
{v}
|
|
390
|
+
</span>
|
|
391
|
+
))}
|
|
392
|
+
</div>
|
|
393
|
+
<div>
|
|
394
|
+
<label className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
|
395
|
+
Title
|
|
396
|
+
</label>
|
|
397
|
+
<input
|
|
398
|
+
type="text"
|
|
399
|
+
value={tplTitle}
|
|
400
|
+
onChange={(e) => setTplTitle(e.target.value)}
|
|
401
|
+
placeholder="Notification title"
|
|
402
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
<div>
|
|
406
|
+
<label className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
|
407
|
+
Body
|
|
408
|
+
</label>
|
|
409
|
+
<textarea
|
|
410
|
+
value={body}
|
|
411
|
+
rows={2}
|
|
412
|
+
onChange={(e) => setBody(e.target.value)}
|
|
413
|
+
placeholder="Notification body"
|
|
414
|
+
className="w-full resize-y rounded-lg border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
415
|
+
/>
|
|
416
|
+
</div>
|
|
417
|
+
<div className="flex justify-end gap-2">
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
onClick={() => setOpen(false)}
|
|
421
|
+
className="px-3 py-1.5 text-xs text-gray-400 transition-colors hover:text-white"
|
|
422
|
+
>
|
|
423
|
+
Cancel
|
|
424
|
+
</button>
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
onClick={handleSave}
|
|
428
|
+
disabled={!tplTitle.trim() && !body.trim()}
|
|
429
|
+
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs text-white transition-colors hover:bg-indigo-700 disabled:opacity-50"
|
|
430
|
+
>
|
|
431
|
+
Save
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function TemplatesTab() {
|
|
441
|
+
const { adminApi, onToast } = useAdminNoti();
|
|
442
|
+
const queryClient = useQueryClient();
|
|
443
|
+
|
|
444
|
+
const { data: templatesResponse, isLoading } = useQuery({
|
|
445
|
+
queryKey: QK.templates,
|
|
446
|
+
queryFn: () => adminApi.notificationTemplates.list(),
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const templates = templatesResponse?.data ?? [];
|
|
450
|
+
|
|
451
|
+
const saveMutation = useMutation({
|
|
452
|
+
mutationFn: ({ id, notificationType, title, body }) => {
|
|
453
|
+
const payload = {
|
|
454
|
+
notificationType,
|
|
455
|
+
channels: { in_app: { title, body } },
|
|
456
|
+
isActive: true,
|
|
457
|
+
};
|
|
458
|
+
return id
|
|
459
|
+
? adminApi.notificationTemplates.update(id, payload)
|
|
460
|
+
: adminApi.notificationTemplates.create(payload);
|
|
461
|
+
},
|
|
462
|
+
onSuccess: () => {
|
|
463
|
+
queryClient.invalidateQueries({ queryKey: QK.templates });
|
|
464
|
+
onToast({
|
|
465
|
+
title: 'Template saved',
|
|
466
|
+
message: 'Notification template updated.',
|
|
467
|
+
variant: 'success',
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
onError: (err) => {
|
|
471
|
+
onToast({
|
|
472
|
+
title: 'Save failed',
|
|
473
|
+
message: err?.message || 'Error',
|
|
474
|
+
variant: 'error',
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if (isLoading) {
|
|
480
|
+
return <p className="text-sm text-gray-500">Loading templates…</p>;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div className="max-w-2xl space-y-3">
|
|
485
|
+
<p className="mb-4 text-xs text-gray-500">
|
|
486
|
+
Configure in-app notification templates for automatic system events. Variables in{' '}
|
|
487
|
+
<span className="font-mono text-gray-400">{'{{curly braces}}'}</span> are replaced at delivery
|
|
488
|
+
time. Requires the <strong className="text-gray-300">In-App</strong> channel to be enabled.
|
|
489
|
+
</p>
|
|
490
|
+
{AUTO_EVENT_TYPES.map((eventDef) => {
|
|
491
|
+
const existing = templates.find((t) => t.notificationType === eventDef.type);
|
|
492
|
+
return (
|
|
493
|
+
<TemplateRow
|
|
494
|
+
key={eventDef.type}
|
|
495
|
+
eventDef={eventDef}
|
|
496
|
+
existing={existing}
|
|
497
|
+
onSave={(args) => saveMutation.mutate(args)}
|
|
498
|
+
/>
|
|
499
|
+
);
|
|
500
|
+
})}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const TABS = [
|
|
506
|
+
{ id: 'send', label: 'Send', icon: Bell },
|
|
507
|
+
{ id: 'channels', label: 'Channels', icon: Radio },
|
|
508
|
+
{ id: 'templates', label: 'Auto-Templates', icon: FileText },
|
|
509
|
+
];
|
|
510
|
+
|
|
511
|
+
function defaultOnToast({ title, message, variant }) {
|
|
512
|
+
const fn = variant === 'error' ? console.error : console.info;
|
|
513
|
+
fn('[novalab-admin-noti-sdk]', title, message);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Provider: truyền client API đã tạo từ `createAdminNotiApi`.
|
|
518
|
+
*/
|
|
519
|
+
export function AdminNotiSdkProvider({ adminApi, onToast = defaultOnToast, children }) {
|
|
520
|
+
const value = useMemo(
|
|
521
|
+
() => ({
|
|
522
|
+
adminApi,
|
|
523
|
+
onToast,
|
|
524
|
+
}),
|
|
525
|
+
[adminApi, onToast]
|
|
526
|
+
);
|
|
527
|
+
return <AdminNotiContext.Provider value={value}>{children}</AdminNotiContext.Provider>;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* UI đầy đủ (Send / Channels / Auto-Templates). Cần bọc `QueryClientProvider` + `AdminNotiSdkProvider`.
|
|
532
|
+
*/
|
|
533
|
+
export function AdminNotificationsPanel({
|
|
534
|
+
className = '',
|
|
535
|
+
heading = 'Notifications',
|
|
536
|
+
subheading = 'Send notifications and configure automatic delivery',
|
|
537
|
+
}) {
|
|
538
|
+
const [activeTab, setActiveTab] = useState('send');
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<div className={`text-gray-100 ${className}`.trim()}>
|
|
542
|
+
<div className="mb-4">
|
|
543
|
+
<h2 className="text-lg font-semibold text-white">{heading}</h2>
|
|
544
|
+
{subheading && <p className="text-sm text-gray-500">{subheading}</p>}
|
|
545
|
+
</div>
|
|
546
|
+
<div className="mb-6 flex gap-1 border-b border-gray-800">
|
|
547
|
+
{TABS.map(({ id, label, icon: Icon }) => (
|
|
548
|
+
<button
|
|
549
|
+
key={id}
|
|
550
|
+
type="button"
|
|
551
|
+
onClick={() => setActiveTab(id)}
|
|
552
|
+
className={`-mb-px flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
553
|
+
activeTab === id
|
|
554
|
+
? 'border-indigo-500 text-indigo-400'
|
|
555
|
+
: 'border-transparent text-gray-500 hover:text-gray-300'
|
|
556
|
+
}`}
|
|
557
|
+
>
|
|
558
|
+
<Icon className="h-4 w-4" />
|
|
559
|
+
{label}
|
|
560
|
+
</button>
|
|
561
|
+
))}
|
|
562
|
+
</div>
|
|
563
|
+
{activeTab === 'send' && <SendTab />}
|
|
564
|
+
{activeTab === 'channels' && <ChannelsTab />}
|
|
565
|
+
{activeTab === 'templates' && <TemplatesTab />}
|
|
566
|
+
</div>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Bọc sẵn QueryClient + API client — chỉ cần `apiBaseUrl` và JWT.
|
|
572
|
+
*/
|
|
573
|
+
export function AdminNotificationsEmbed({
|
|
574
|
+
apiBaseUrl,
|
|
575
|
+
getAccessToken,
|
|
576
|
+
adminApi: adminApiProp,
|
|
577
|
+
onToast,
|
|
578
|
+
className,
|
|
579
|
+
heading,
|
|
580
|
+
subheading,
|
|
581
|
+
}) {
|
|
582
|
+
const adminApi = useMemo(() => {
|
|
583
|
+
if (adminApiProp) return adminApiProp;
|
|
584
|
+
if (!apiBaseUrl || getAccessToken == null) {
|
|
585
|
+
throw new Error('AdminNotificationsEmbed requires either `adminApi` or both `apiBaseUrl` and `getAccessToken`');
|
|
586
|
+
}
|
|
587
|
+
return createAdminNotiApi({ apiBaseUrl, getAccessToken });
|
|
588
|
+
}, [adminApiProp, apiBaseUrl, getAccessToken]);
|
|
589
|
+
|
|
590
|
+
const [queryClient] = useState(() => new QueryClient());
|
|
591
|
+
|
|
592
|
+
return (
|
|
593
|
+
<QueryClientProvider client={queryClient}>
|
|
594
|
+
<AdminNotiSdkProvider adminApi={adminApi} onToast={onToast}>
|
|
595
|
+
<AdminNotificationsPanel
|
|
596
|
+
className={className}
|
|
597
|
+
heading={heading}
|
|
598
|
+
subheading={subheading}
|
|
599
|
+
/>
|
|
600
|
+
</AdminNotiSdkProvider>
|
|
601
|
+
</QueryClientProvider>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} opts
|
|
5
|
+
* @param {string} opts.apiBaseUrl - Gốc API không có /v1 (vd: https://api.example.com)
|
|
6
|
+
* @param {string | (() => string | Promise<string>)} opts.getAccessToken - JWT admin
|
|
7
|
+
*/
|
|
8
|
+
export function createAdminNotiApi({ apiBaseUrl, getAccessToken }) {
|
|
9
|
+
const base = String(apiBaseUrl || '').replace(/\/$/, '');
|
|
10
|
+
const api = axios.create({
|
|
11
|
+
baseURL: `${base}/v1`,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
api.interceptors.request.use(async (config) => {
|
|
16
|
+
const token =
|
|
17
|
+
typeof getAccessToken === 'function' ? await getAccessToken() : getAccessToken;
|
|
18
|
+
if (token) {
|
|
19
|
+
config.headers = config.headers || {};
|
|
20
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
21
|
+
}
|
|
22
|
+
return config;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
api.interceptors.response.use(
|
|
26
|
+
(res) => {
|
|
27
|
+
if (res.data?.success === true) return res.data;
|
|
28
|
+
return res.data ?? res;
|
|
29
|
+
},
|
|
30
|
+
(err) => {
|
|
31
|
+
const responseData = err?.response?.data;
|
|
32
|
+
const payload = responseData?.error || {
|
|
33
|
+
code: 'NETWORK_ERROR',
|
|
34
|
+
message: err?.message || 'Request failed',
|
|
35
|
+
};
|
|
36
|
+
return Promise.reject(payload);
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const prefix = '/admin';
|
|
41
|
+
return {
|
|
42
|
+
notifications: {
|
|
43
|
+
push: (body) => api.post(`${prefix}/notifications`, body),
|
|
44
|
+
},
|
|
45
|
+
channels: {
|
|
46
|
+
list: () => api.get(`${prefix}/channels`),
|
|
47
|
+
create: (body) => api.post(`${prefix}/channels`, body),
|
|
48
|
+
update: (id, body) => api.patch(`${prefix}/channels/${id}`, body),
|
|
49
|
+
},
|
|
50
|
+
notificationTemplates: {
|
|
51
|
+
list: () => api.get(`${prefix}/notification-templates`),
|
|
52
|
+
create: (body) => api.post(`${prefix}/notification-templates`, body),
|
|
53
|
+
update: (id, body) => api.put(`${prefix}/notification-templates/${id}`, body),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|