@ayush0x44/notifystack 1.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/README.md +67 -0
- package/index.d.ts +67 -0
- package/index.js +267 -0
- package/package.json +31 -0
- package/react.jsx +134 -0
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# NotifyStack Node.js SDK
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@ayush0x44/notifystack)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
The official Node.js SDK for **NotifyStack** — a high-performance, distributed notification SaaS platform. Send Emails, SMS, Push, and In-App notifications with a single unified API.
|
|
7
|
+
|
|
8
|
+
## 🚀 Features
|
|
9
|
+
|
|
10
|
+
- **Zero Dependencies**: Lightweight and fast (uses native `fetch`).
|
|
11
|
+
- **Unified API**: One interface for all channels (Email, SMS, Push, In-App).
|
|
12
|
+
- **Auto-Retry**: Built-in exponential backoff for network flakes.
|
|
13
|
+
- **Idempotency**: Safe retries without duplicate notifications.
|
|
14
|
+
- **Batching**: Send up to 100 notifications in a single call.
|
|
15
|
+
|
|
16
|
+
## 📦 Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @ayush0x44/notifystack
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 🛠️ Quick Start
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
const { NotifySDK } = require("@ayush0x44/notifystack");
|
|
26
|
+
|
|
27
|
+
// Initialize the client
|
|
28
|
+
const notify = new NotifySDK("ntf_live_your_api_key", {
|
|
29
|
+
baseUrl: "https://notificationsaas.onrender.com" // Point to your cloud API
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
// 1. Send an Event-based notification (uses templates)
|
|
34
|
+
await notify.track("ORDER_PLACED", {
|
|
35
|
+
email: "customer@example.com",
|
|
36
|
+
orderId: "ORD-123"
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 2. Send a direct Email
|
|
40
|
+
await notify.send({
|
|
41
|
+
to: "hello@world.com",
|
|
42
|
+
subject: "Welcome!",
|
|
43
|
+
body: "Thanks for joining our platform."
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// 3. Send an SMS
|
|
47
|
+
await notify.sendSms({
|
|
48
|
+
to: "+1234567890",
|
|
49
|
+
body: "Your verification code is 1234"
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch(console.error);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## ⚙️ Configuration
|
|
57
|
+
|
|
58
|
+
| Option | Type | Default | Description |
|
|
59
|
+
| :--- | :--- | :--- | :--- |
|
|
60
|
+
| `baseUrl` | `string` | `http://localhost:3000` | The URL of your NotifyStack API |
|
|
61
|
+
| `maxRetries` | `number` | `3` | Max attempts for failed requests |
|
|
62
|
+
| `timeoutMs` | `number` | `10000` | Request timeout duration |
|
|
63
|
+
| `debug` | `boolean` | `false` | Enable verbose logging |
|
|
64
|
+
|
|
65
|
+
## 📖 License
|
|
66
|
+
|
|
67
|
+
MIT © [Ayush](https://github.com/ayush462)
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
declare class NotifySDK {
|
|
2
|
+
constructor(apiKey: string, options?: {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
maxRetries?: number;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
debug?: boolean;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/** Send event-based notification using a template */
|
|
10
|
+
track(eventName: string, data: Record<string, any> & { email: string }, options?: {
|
|
11
|
+
metadata?: Record<string, any>;
|
|
12
|
+
channel?: "email" | "sms" | "push";
|
|
13
|
+
priority?: "low" | "normal" | "high";
|
|
14
|
+
}): Promise<{ id: string; status: string }>;
|
|
15
|
+
|
|
16
|
+
/** Send a direct email */
|
|
17
|
+
send(params: {
|
|
18
|
+
to: string;
|
|
19
|
+
subject: string;
|
|
20
|
+
body: string;
|
|
21
|
+
metadata?: Record<string, any>;
|
|
22
|
+
}): Promise<{ id: string; status: string }>;
|
|
23
|
+
|
|
24
|
+
/** Send an SMS */
|
|
25
|
+
sendSms(params: {
|
|
26
|
+
to: string;
|
|
27
|
+
body: string;
|
|
28
|
+
metadata?: Record<string, any>;
|
|
29
|
+
}): Promise<{ id: string; status: string }>;
|
|
30
|
+
|
|
31
|
+
/** Send a push notification */
|
|
32
|
+
sendPush(params: {
|
|
33
|
+
token: string;
|
|
34
|
+
title: string;
|
|
35
|
+
body: string;
|
|
36
|
+
data?: Record<string, any>;
|
|
37
|
+
metadata?: Record<string, any>;
|
|
38
|
+
}): Promise<{ id: string; status: string }>;
|
|
39
|
+
|
|
40
|
+
/** Send batch notifications (max 100) */
|
|
41
|
+
sendBatch(notifications: Array<Record<string, any>>): Promise<{
|
|
42
|
+
results: Array<{ index: number; id: string; status: string }>;
|
|
43
|
+
errors: Array<{ index: number; error: string }>;
|
|
44
|
+
total: number;
|
|
45
|
+
succeeded: number;
|
|
46
|
+
failed: number;
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
/** List notifications */
|
|
50
|
+
listNotifications(params?: {
|
|
51
|
+
limit?: number;
|
|
52
|
+
offset?: number;
|
|
53
|
+
status?: string;
|
|
54
|
+
}): Promise<any>;
|
|
55
|
+
|
|
56
|
+
/** Check API health */
|
|
57
|
+
health(): Promise<{ ok: boolean }>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
declare class NotifyError extends Error {
|
|
61
|
+
status: number;
|
|
62
|
+
response: any;
|
|
63
|
+
constructor(message: string, status: number, response: any);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export = NotifySDK;
|
|
67
|
+
export { NotifyError };
|
package/index.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NotifyStack SDK — Production-grade notification client.
|
|
5
|
+
*
|
|
6
|
+
* Supports: Email, SMS, Push notifications
|
|
7
|
+
* Features: Auto-retry, idempotency, batch sending, debug mode, rate limit handling
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const NotifySDK = require("notify-saas-sdk");
|
|
11
|
+
* const notify = new NotifySDK("ntf_live_xxxxxxxxx");
|
|
12
|
+
* await notify.track("USER_LOGIN", { email: "user@email.com", name: "Ayush" });
|
|
13
|
+
*/
|
|
14
|
+
class NotifySDK {
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} apiKey - Your NotifyStack API key (ntf_live_xxx)
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {string} [options.baseUrl="http://localhost:3000"] - API base URL
|
|
19
|
+
* @param {number} [options.maxRetries=3] - Max retry attempts
|
|
20
|
+
* @param {number} [options.timeoutMs=10000] - Request timeout in ms
|
|
21
|
+
* @param {boolean} [options.debug=false] - Enable verbose logging
|
|
22
|
+
*/
|
|
23
|
+
constructor(apiKey, options = {}) {
|
|
24
|
+
if (!apiKey || !apiKey.startsWith("ntf_live_")) {
|
|
25
|
+
throw new Error("Invalid API key. Must start with 'ntf_live_'");
|
|
26
|
+
}
|
|
27
|
+
this.apiKey = apiKey;
|
|
28
|
+
this.baseUrl = (options.baseUrl || "http://localhost:3000").replace(/\/$/, "");
|
|
29
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
30
|
+
this.timeoutMs = options.timeoutMs ?? 10000;
|
|
31
|
+
this.debug = options.debug || false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_log(...args) {
|
|
35
|
+
if (this.debug) console.log("[NotifySDK]", ...args);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Send an event-based notification using a registered template.
|
|
40
|
+
* @param {string} eventName - Event name (e.g. "USER_LOGIN", "ORDER_PLACED")
|
|
41
|
+
* @param {object} data - Template variables (must include `email`)
|
|
42
|
+
* @param {object} [options] - { metadata, channel, priority }
|
|
43
|
+
* @returns {Promise<{id: string, status: string}>}
|
|
44
|
+
*/
|
|
45
|
+
async track(eventName, data, options = {}) {
|
|
46
|
+
if (!eventName) throw new Error("eventName is required");
|
|
47
|
+
if (!data || !data.email) throw new Error("data.email is required for event-based notifications");
|
|
48
|
+
|
|
49
|
+
this._log(`track(${eventName})`, data);
|
|
50
|
+
return this._request("POST", "/v1/notifications", {
|
|
51
|
+
event: eventName,
|
|
52
|
+
data,
|
|
53
|
+
channel: options.channel || "email",
|
|
54
|
+
metadata: options.metadata || options
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Send a direct email notification (no template).
|
|
60
|
+
* @param {object} params
|
|
61
|
+
* @param {string} params.to - Recipient email
|
|
62
|
+
* @param {string} params.subject - Email subject
|
|
63
|
+
* @param {string} params.body - Email body
|
|
64
|
+
* @param {object} [params.metadata]
|
|
65
|
+
* @returns {Promise<{id: string, status: string}>}
|
|
66
|
+
*/
|
|
67
|
+
async send({ to, subject, body, metadata }) {
|
|
68
|
+
if (!to || !subject || !body) throw new Error("to, subject, and body are required");
|
|
69
|
+
|
|
70
|
+
this._log(`send() to=${to}`);
|
|
71
|
+
return this._request("POST", "/v1/notifications", {
|
|
72
|
+
recipientEmail: to,
|
|
73
|
+
subject,
|
|
74
|
+
body,
|
|
75
|
+
channel: "email",
|
|
76
|
+
metadata
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Send an SMS notification.
|
|
82
|
+
* @param {object} params
|
|
83
|
+
* @param {string} params.to - Phone number (E.164 format)
|
|
84
|
+
* @param {string} params.body - SMS body (max 1600 chars)
|
|
85
|
+
* @param {object} [params.metadata]
|
|
86
|
+
* @returns {Promise<{id: string, status: string}>}
|
|
87
|
+
*/
|
|
88
|
+
async sendSms({ to, body, metadata }) {
|
|
89
|
+
if (!to || !body) throw new Error("to and body are required");
|
|
90
|
+
if (body.length > 1600) throw new Error("SMS body exceeds 1600 character limit");
|
|
91
|
+
|
|
92
|
+
this._log(`sendSms() to=${to}`);
|
|
93
|
+
return this._request("POST", "/v1/notifications", {
|
|
94
|
+
recipientPhone: to,
|
|
95
|
+
subject: "SMS",
|
|
96
|
+
body,
|
|
97
|
+
channel: "sms",
|
|
98
|
+
metadata
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Send a push notification.
|
|
104
|
+
* @param {object} params
|
|
105
|
+
* @param {string} params.token - Device token or subscription JSON
|
|
106
|
+
* @param {string} params.title - Notification title
|
|
107
|
+
* @param {string} params.body - Notification body
|
|
108
|
+
* @param {object} [params.data] - Custom data payload
|
|
109
|
+
* @param {object} [params.metadata]
|
|
110
|
+
* @returns {Promise<{id: string, status: string}>}
|
|
111
|
+
*/
|
|
112
|
+
async sendPush({ token, title, body, data, metadata }) {
|
|
113
|
+
if (!token || !title || !body) throw new Error("token, title, and body are required");
|
|
114
|
+
|
|
115
|
+
this._log(`sendPush() title=${title}`);
|
|
116
|
+
return this._request("POST", "/v1/notifications", {
|
|
117
|
+
deviceToken: token,
|
|
118
|
+
subject: title,
|
|
119
|
+
body,
|
|
120
|
+
channel: "push",
|
|
121
|
+
metadata: { ...metadata, pushData: data }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Send multiple notifications in a batch.
|
|
127
|
+
* @param {Array<object>} notifications - Array of notification payloads
|
|
128
|
+
* @returns {Promise<Array<{id: string, status: string}>>}
|
|
129
|
+
*/
|
|
130
|
+
async sendBatch(notifications) {
|
|
131
|
+
if (!Array.isArray(notifications) || !notifications.length) {
|
|
132
|
+
throw new Error("notifications must be a non-empty array");
|
|
133
|
+
}
|
|
134
|
+
if (notifications.length > 100) {
|
|
135
|
+
throw new Error("Batch size cannot exceed 100");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this._log(`sendBatch() count=${notifications.length}`);
|
|
139
|
+
const results = [];
|
|
140
|
+
const errors = [];
|
|
141
|
+
|
|
142
|
+
// Process in parallel with concurrency limit of 10
|
|
143
|
+
const chunks = [];
|
|
144
|
+
for (let i = 0; i < notifications.length; i += 10) {
|
|
145
|
+
chunks.push(notifications.slice(i, i + 10));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const chunk of chunks) {
|
|
149
|
+
const promises = chunk.map(async (n, idx) => {
|
|
150
|
+
try {
|
|
151
|
+
const result = await this._request("POST", "/v1/notifications", n);
|
|
152
|
+
results.push({ index: idx, ...result });
|
|
153
|
+
} catch (e) {
|
|
154
|
+
errors.push({ index: idx, error: e.message });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
await Promise.all(promises);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { results, errors, total: notifications.length, succeeded: results.length, failed: errors.length };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* List notifications for the project.
|
|
165
|
+
* @param {object} [params]
|
|
166
|
+
* @param {number} [params.limit=50]
|
|
167
|
+
* @param {number} [params.offset=0]
|
|
168
|
+
* @param {string} [params.status]
|
|
169
|
+
* @returns {Promise<object>}
|
|
170
|
+
*/
|
|
171
|
+
async listNotifications(params = {}) {
|
|
172
|
+
const qs = new URLSearchParams();
|
|
173
|
+
if (params.limit) qs.set("limit", String(params.limit));
|
|
174
|
+
if (params.offset) qs.set("offset", String(params.offset));
|
|
175
|
+
if (params.status) qs.set("status", params.status);
|
|
176
|
+
const query = qs.toString();
|
|
177
|
+
return this._request("GET", `/v1/notifications${query ? "?" + query : ""}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check API health.
|
|
182
|
+
* @returns {Promise<{ok: boolean}>}
|
|
183
|
+
*/
|
|
184
|
+
async health() {
|
|
185
|
+
return this._request("GET", "/health");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Internal: HTTP request with retry, idempotency, and 429 handling.
|
|
190
|
+
*/
|
|
191
|
+
async _request(method, path, body) {
|
|
192
|
+
const idempotencyKey = `idem_${crypto.randomUUID()}`;
|
|
193
|
+
let lastError;
|
|
194
|
+
|
|
195
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
196
|
+
const start = Date.now();
|
|
197
|
+
try {
|
|
198
|
+
const url = `${this.baseUrl}${path}`;
|
|
199
|
+
const headers = {
|
|
200
|
+
"Content-Type": "application/json",
|
|
201
|
+
"x-api-key": this.apiKey,
|
|
202
|
+
"x-idempotency-key": idempotencyKey,
|
|
203
|
+
"User-Agent": "NotifyStack-SDK/2.0"
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
208
|
+
|
|
209
|
+
const fetchOptions = { method, headers, signal: controller.signal };
|
|
210
|
+
if (body && method !== "GET") fetchOptions.body = JSON.stringify(body);
|
|
211
|
+
|
|
212
|
+
const response = await fetch(url, fetchOptions);
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
|
|
215
|
+
const json = await response.json();
|
|
216
|
+
const latency = Date.now() - start;
|
|
217
|
+
|
|
218
|
+
this._log(`${method} ${path} → ${response.status} (${latency}ms)`);
|
|
219
|
+
|
|
220
|
+
if (response.ok) return json.data || json;
|
|
221
|
+
|
|
222
|
+
// Rate limited — wait and retry
|
|
223
|
+
if (response.status === 429) {
|
|
224
|
+
const retryAfter = response.headers.get("retry-after");
|
|
225
|
+
const waitMs = retryAfter ? Number(retryAfter) * 1000 : 5000;
|
|
226
|
+
this._log(`Rate limited. Waiting ${waitMs}ms...`);
|
|
227
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Don't retry client errors (4xx)
|
|
232
|
+
if (response.status >= 400 && response.status < 500) {
|
|
233
|
+
throw new NotifyError(json.message || "Request failed", response.status, json);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
lastError = new NotifyError(json.message || "Request failed", response.status, json);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e instanceof NotifyError && e.status >= 400 && e.status < 500) throw e;
|
|
239
|
+
lastError = e;
|
|
240
|
+
this._log(`Attempt ${attempt + 1} failed: ${e.message}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Exponential backoff with jitter
|
|
244
|
+
if (attempt < this.maxRetries - 1) {
|
|
245
|
+
const base = Math.min(1000 * Math.pow(2, attempt), 10000);
|
|
246
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
247
|
+
const delay = Math.round(base + jitter);
|
|
248
|
+
this._log(`Retrying in ${delay}ms...`);
|
|
249
|
+
await new Promise(r => setTimeout(r, delay));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw lastError || new Error("Request failed after retries");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
class NotifyError extends Error {
|
|
258
|
+
constructor(message, status, response) {
|
|
259
|
+
super(message);
|
|
260
|
+
this.name = "NotifyError";
|
|
261
|
+
this.status = status;
|
|
262
|
+
this.response = response;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = NotifySDK;
|
|
267
|
+
module.exports.NotifyError = NotifyError;
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayush0x44/notifystack",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The official Node.js SDK for NotifyStack — a scalable, production-ready notification SaaS platform.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ayush462/notificationSaas.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"notifications",
|
|
15
|
+
"email",
|
|
16
|
+
"sms",
|
|
17
|
+
"push",
|
|
18
|
+
"saas",
|
|
19
|
+
"notifystack",
|
|
20
|
+
"node-sdk"
|
|
21
|
+
],
|
|
22
|
+
"author": "Ayush",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/ayush462/notificationSaas/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/ayush462/notificationSaas#readme",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/react.jsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 🔔 NotifyStack In-App Notification Hook
|
|
5
|
+
*/
|
|
6
|
+
export function useNotifyStack(apiKey, userId, options = {}) {
|
|
7
|
+
const [notifications, setNotifications] = useState([]);
|
|
8
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
9
|
+
const [loading, setLoading] = useState(false);
|
|
10
|
+
const baseUrl = options.baseUrl || "https://api.notifystack.com";
|
|
11
|
+
|
|
12
|
+
const fetchFeed = async () => {
|
|
13
|
+
if (!userId) return;
|
|
14
|
+
setLoading(true);
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(`${baseUrl}/v1/inapp/${userId}`, {
|
|
17
|
+
headers: { "x-api-key": apiKey }
|
|
18
|
+
});
|
|
19
|
+
const json = await res.json();
|
|
20
|
+
if (json.success) {
|
|
21
|
+
setNotifications(json.data.items);
|
|
22
|
+
setUnreadCount(json.data.unreadCount);
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error("NotifyStack fetch error:", err);
|
|
26
|
+
} finally {
|
|
27
|
+
setLoading(false);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
fetchFeed();
|
|
33
|
+
// Use SSE or poll
|
|
34
|
+
const interval = setInterval(fetchFeed, options.pollingInterval || 15000);
|
|
35
|
+
return () => clearInterval(interval);
|
|
36
|
+
}, [userId, apiKey, baseUrl]);
|
|
37
|
+
|
|
38
|
+
const markAsRead = async (notificationId) => {
|
|
39
|
+
try {
|
|
40
|
+
setUnreadCount(Math.max(0, unreadCount - 1));
|
|
41
|
+
setNotifications(prev => prev.map(n => n.id === notificationId ? { ...n, read_at: new Date().toISOString() } : n));
|
|
42
|
+
|
|
43
|
+
await fetch(`${baseUrl}/v1/inapp/${userId}/read/${notificationId}`, {
|
|
44
|
+
method: "PATCH",
|
|
45
|
+
headers: { "x-api-key": apiKey }
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error("Failed to mark as read:", err);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const markAllAsRead = async () => {
|
|
53
|
+
try {
|
|
54
|
+
setUnreadCount(0);
|
|
55
|
+
setNotifications(prev => prev.map(n => ({ ...n, read_at: new Date().toISOString() })));
|
|
56
|
+
|
|
57
|
+
await fetch(`${baseUrl}/v1/inapp/${userId}/read`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "x-api-key": apiKey }
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error("Failed to mark all as read:", err);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return { notifications, unreadCount, loading, markAsRead, markAllAsRead, refresh: fetchFeed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 🔔 NotifyStack In-App Bell UI Component (Tailwind required)
|
|
71
|
+
*/
|
|
72
|
+
export function NotificationBell({ apiKey, userId, baseUrl }) {
|
|
73
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
74
|
+
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifyStack(apiKey, userId, { baseUrl });
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="relative">
|
|
78
|
+
<button
|
|
79
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
80
|
+
className="relative p-2 rounded-full hover:bg-gray-100 transition-colors"
|
|
81
|
+
>
|
|
82
|
+
<svg fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6 text-gray-700">
|
|
83
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
|
84
|
+
</svg>
|
|
85
|
+
{unreadCount > 0 && (
|
|
86
|
+
<div className="absolute top-1 right-1 w-4 h-4 rounded-full bg-red-500 text-[10px] font-bold text-white flex items-center justify-center">
|
|
87
|
+
{unreadCount}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</button>
|
|
91
|
+
|
|
92
|
+
{isOpen && (
|
|
93
|
+
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 overflow-hidden">
|
|
94
|
+
<div className="flex justify-between items-center p-4 border-b border-gray-50 bg-gray-50/50">
|
|
95
|
+
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
|
96
|
+
{unreadCount > 0 && (
|
|
97
|
+
<button onClick={markAllAsRead} className="text-xs text-blue-600 hover:text-blue-800 font-medium">
|
|
98
|
+
Mark all read
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
<div className="max-h-[400px] overflow-y-auto">
|
|
103
|
+
{notifications.length === 0 ? (
|
|
104
|
+
<div className="p-8 text-center text-gray-500 text-sm">
|
|
105
|
+
You have no notifications yet.
|
|
106
|
+
</div>
|
|
107
|
+
) : (
|
|
108
|
+
<div className="divide-y divide-gray-50">
|
|
109
|
+
{notifications.map(n => (
|
|
110
|
+
<div
|
|
111
|
+
key={n.id}
|
|
112
|
+
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${!n.read_at && 'bg-blue-50/50'}`}
|
|
113
|
+
onClick={() => !n.read_at && markAsRead(n.id)}
|
|
114
|
+
>
|
|
115
|
+
<div className="flex justify-between items-start mb-1">
|
|
116
|
+
<p className={`text-sm ${!n.read_at ? 'font-semibold text-gray-900' : 'text-gray-700'}`}>
|
|
117
|
+
{n.subject}
|
|
118
|
+
</p>
|
|
119
|
+
{!n.read_at && <div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 shrink-0" />}
|
|
120
|
+
</div>
|
|
121
|
+
<p className="text-sm text-gray-500 line-clamp-2">{n.body}</p>
|
|
122
|
+
<p className="text-[11px] text-gray-400 mt-2">
|
|
123
|
+
{new Date(n.created_at).toLocaleDateString()}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|