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 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
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createAdminNotiApi } from './createAdminNotiApi.js';
2
+ export {
3
+ AdminNotiSdkProvider,
4
+ AdminNotificationsPanel,
5
+ AdminNotificationsEmbed,
6
+ } from './AdminNotificationsPanel.jsx';