@visns-studio/visns-components 5.7.0 → 5.7.1

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.
@@ -1,29 +1,33 @@
1
- import React, { useState, useEffect, useRef } from 'react';
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
3
  import moment from 'moment';
4
4
  import parse from 'html-react-parser';
5
5
  import { toast } from 'react-toastify';
6
- import { Bell, CircleX } from 'akar-icons';
6
+ import { Bell, CircleX, Check } from 'akar-icons';
7
7
  import CustomFetch from './Fetch';
8
8
  import styles from './styles/Notification.module.scss';
9
9
 
10
10
  function Notification(props) {
11
- let interval;
12
11
  const navigate = useNavigate();
13
12
  const wrapperRef = useRef(null);
14
13
  const [items, setItems] = useState([]);
15
14
  const [show, setShow] = useState(false);
15
+ const [loading, setLoading] = useState(false);
16
+ const [processingIds, setProcessingIds] = useState([]);
16
17
  const { setSystemAuth } = props;
17
18
 
18
- const fetchNotification = () => {
19
+ const fetchNotification = useCallback(() => {
20
+ setLoading(true);
19
21
  CustomFetch(
20
22
  '/ajax/user/notifications',
21
23
  'POST',
22
24
  {},
23
25
  function (result) {
24
26
  setItems(result.data);
27
+ setLoading(false);
25
28
  },
26
29
  function (error) {
30
+ setLoading(false);
27
31
  if (String(error) === 'CSRF token mismatch.') {
28
32
  window.open('/login');
29
33
  } else {
@@ -34,163 +38,290 @@ function Notification(props) {
34
38
  }
35
39
  }
36
40
  );
37
- };
38
-
39
- const handleClick = (e) => {
40
- if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
41
- setShow(false);
42
- }
43
- };
44
-
45
- const markAsRead = (e) => {
46
- e.preventDefault();
41
+ }, [setSystemAuth]);
47
42
 
48
- let array = items;
49
- let index = e.target.dataset.index;
50
- let id = e.target.dataset.id;
43
+ const handleClickOutside = useCallback((e) => {
44
+ // Check if the click is outside the notification dropdown and not on the bell icon
45
+ const bellButton = document.querySelector(
46
+ `.${styles.notificationTrigger}`
47
+ );
48
+ const isClickOnBell = bellButton && bellButton.contains(e.target);
51
49
 
52
- if (index >= 0) {
53
- array.splice(index, 1);
54
- setItems(array);
50
+ if (
51
+ wrapperRef.current &&
52
+ !wrapperRef.current.contains(e.target) &&
53
+ !isClickOnBell
54
+ ) {
55
+ setShow(false);
55
56
  }
57
+ }, []);
56
58
 
57
- let formData = { id: id };
59
+ const markAsRead = useCallback(
60
+ (e) => {
61
+ e.preventDefault();
62
+ e.stopPropagation();
58
63
 
59
- CustomFetch(
60
- '/ajax/user/notification/markasread',
61
- 'POST',
62
- formData,
63
- function () {
64
- fetchNotification();
65
- }
66
- );
67
- };
64
+ const target = e.currentTarget;
65
+ const id = target.dataset.id;
66
+ const index = parseInt(target.dataset.index);
68
67
 
69
- const markAllAsRead = (e) => {
70
- e.preventDefault();
68
+ if (!id || processingIds.includes(id)) return;
71
69
 
72
- let array = items;
73
- setItems([]);
70
+ // Optimistic UI update
71
+ setProcessingIds((prev) => [...prev, id]);
72
+ setItems((prevItems) =>
73
+ prevItems.filter((_, idx) => idx !== index)
74
+ );
74
75
 
75
- array.map((item) => {
76
- let formData = { id: item.id };
76
+ const formData = { id };
77
77
 
78
78
  CustomFetch(
79
79
  '/ajax/user/notification/markasread',
80
80
  'POST',
81
81
  formData,
82
82
  function () {
83
- fetchNotification();
83
+ setProcessingIds((prev) =>
84
+ prev.filter((item) => item !== id)
85
+ );
86
+ },
87
+ function (error) {
88
+ setProcessingIds((prev) =>
89
+ prev.filter((item) => item !== id)
90
+ );
91
+ toast.error(
92
+ String(error) || 'Failed to mark notification as read'
93
+ );
94
+ fetchNotification(); // Refresh to get correct state
84
95
  }
85
96
  );
86
- });
87
- };
97
+ },
98
+ [fetchNotification, processingIds]
99
+ );
88
100
 
89
- const toggleNotification = () => {
90
- setShow(!show);
91
- };
101
+ const markAllAsRead = useCallback(
102
+ (e) => {
103
+ e.preventDefault();
104
+
105
+ if (items.length === 0 || loading) return;
106
+
107
+ setLoading(true);
108
+ // Optimistic UI update
109
+ setItems([]);
110
+
111
+ const promises = items.map((item) => {
112
+ return new Promise((resolve, reject) => {
113
+ CustomFetch(
114
+ '/ajax/user/notification/markasread',
115
+ 'POST',
116
+ { id: item.id },
117
+ resolve,
118
+ reject
119
+ );
120
+ });
121
+ });
122
+
123
+ Promise.all(promises)
124
+ .then(() => {
125
+ toast.success('All notifications marked as read');
126
+ setLoading(false);
127
+ })
128
+ .catch(() => {
129
+ toast.error('Failed to mark all notifications as read');
130
+ fetchNotification();
131
+ setLoading(false);
132
+ });
133
+ },
134
+ [items, fetchNotification, loading]
135
+ );
136
+
137
+ const toggleNotification = useCallback((e) => {
138
+ e.stopPropagation(); // Prevent event bubbling
139
+ setShow((prev) => !prev);
140
+ }, []);
92
141
 
93
- const showAllNotifications = () => {
142
+ const showAllNotifications = useCallback(() => {
94
143
  navigate('/notifications');
95
144
  setShow(false);
96
- };
145
+ }, [navigate]);
97
146
 
98
147
  useEffect(() => {
99
148
  fetchNotification();
100
149
 
101
- interval = setInterval(() => {
150
+ const interval = setInterval(() => {
102
151
  if (!document.hidden) {
103
152
  fetchNotification();
104
153
  }
105
154
  }, 60000);
106
155
 
107
- document.addEventListener('mousedown', handleClick, false);
156
+ document.addEventListener('mousedown', handleClickOutside, false);
108
157
 
109
158
  return function cleanup() {
110
159
  clearInterval(interval);
111
- document.removeEventListener('mousedown', handleClick, false);
160
+ document.removeEventListener(
161
+ 'mousedown',
162
+ handleClickOutside,
163
+ false
164
+ );
112
165
  };
113
- }, []);
166
+ }, [fetchNotification, handleClickOutside]);
167
+
168
+ const formatTimeAgo = (date) => {
169
+ const now = moment();
170
+ const notificationTime = moment(date);
171
+ const diffHours = now.diff(notificationTime, 'hours');
172
+
173
+ if (diffHours < 24) {
174
+ return notificationTime.fromNow(); // e.g. "2 hours ago"
175
+ } else {
176
+ return notificationTime.format('DD MMM, HH:mm'); // e.g. "15 Jun, 14:30"
177
+ }
178
+ };
179
+
180
+ const hasNotifications = items.length > 0;
114
181
 
115
182
  return (
116
- <>
117
- <a
118
- href="#"
183
+ <div className={styles.notificationContainer}>
184
+ <button
185
+ className={styles.notificationTrigger}
119
186
  onClick={toggleNotification}
120
187
  data-tooltip-id="system-tooltip"
121
- data-tooltip-content="Notification"
188
+ data-tooltip-content="Notifications"
189
+ aria-label="Notifications"
122
190
  >
123
191
  <Bell
124
- size={19}
192
+ size={20}
125
193
  strokeWidth={2}
126
- className={
127
- items.length > 0
128
- ? styles.noti
129
- : styles['cis-bell-exclamation']
130
- }
194
+ className={hasNotifications ? styles.noti : ''}
131
195
  />
132
- </a>
196
+ {hasNotifications && (
197
+ <span className={styles.badge}>{items.length}</span>
198
+ )}
199
+ </button>
133
200
 
134
201
  {show && (
135
- <div ref={wrapperRef}>
136
- {items.length > 0 ? (
137
- <div className={styles.notibox}>
138
- <div className={styles.notiscrollwrap}>
139
- {items.map((item, index) => (
202
+ <div className={styles.dropdownWrapper} ref={wrapperRef}>
203
+ <div className={styles.notibox}>
204
+ <div className={styles.notiboxHeader}>
205
+ <h3>Notifications</h3>
206
+ {hasNotifications && (
207
+ <button
208
+ className={styles.markAllReadBtn}
209
+ onClick={markAllAsRead}
210
+ disabled={loading}
211
+ >
212
+ <Check size={16} />
213
+ <span>Mark all as read</span>
214
+ </button>
215
+ )}
216
+ </div>
217
+
218
+ <div className={styles.notiscrollwrap}>
219
+ {loading && items.length === 0 ? (
220
+ <div className={styles.loadingState}>
221
+ <div
222
+ className={styles.loadingSpinner}
223
+ ></div>
224
+ <p>Loading notifications...</p>
225
+ </div>
226
+ ) : hasNotifications ? (
227
+ items.map((item, index) => (
140
228
  <div
141
- className={styles['notibox-item']}
229
+ className={styles.notiboxItem}
142
230
  key={item.id}
143
231
  >
144
- <Bell
145
- size={19}
146
- strokeWidth={2}
147
- className={
148
- styles.cisBellExclamation
149
- }
150
- />{' '}
151
- {parse(item.data.label)}{' '}
152
- <span>
153
- [
154
- {moment(item.created_at).format(
155
- 'DD/MM - H:mma'
156
- )}
157
- ]
158
- </span>{' '}
159
- <button onClick={markAsRead}>
160
- <CircleX
161
- size={24}
232
+ <div className={styles.notiContent}>
233
+ <div className={styles.notiIcon}>
234
+ <Bell
235
+ size={16}
236
+ strokeWidth={2}
237
+ />
238
+ </div>
239
+ <div className={styles.notiMessage}>
240
+ <div
241
+ className={styles.notiText}
242
+ >
243
+ {parse(item.data.label)}
244
+ </div>
245
+ <div
246
+ className={styles.notiTime}
247
+ >
248
+ {formatTimeAgo(
249
+ item.created_at
250
+ )}
251
+ </div>
252
+ </div>
253
+ <button
254
+ className={styles.notiDismiss}
255
+ onClick={markAsRead}
162
256
  data-id={item.id}
163
257
  data-index={index}
164
- className={styles.notiRead}
165
- />
166
- </button>
258
+ aria-label="Dismiss notification"
259
+ >
260
+ <CircleX size={18} />
261
+ </button>
262
+ </div>
167
263
  </div>
168
- ))}
169
- </div>
170
- <div className={styles['notibox-actions']}>
171
- <button onClick={showAllNotifications}>
172
- View All
173
- </button>
174
- <button onClick={markAllAsRead}>
175
- Clear All
176
- </button>
177
- </div>
264
+ ))
265
+ ) : (
266
+ <div className={styles.emptyState}>
267
+ <div className={styles.emptyIcon}>
268
+ <Bell size={24} strokeWidth={2} />
269
+ </div>
270
+ <p>No new notifications</p>
271
+ </div>
272
+ )}
178
273
  </div>
179
- ) : (
180
- <div className={styles.notibox}>
181
- <div className={styles['notibox-item']}>
182
- No new notifications available.
183
- </div>
184
- <div className={styles['notibox-actions']}>
185
- <button onClick={showAllNotifications}>
186
- View All
274
+
275
+ <div className={styles.notiboxActions}>
276
+ <div className={styles.buttonWrapper}>
277
+ <button
278
+ className={styles.viewAllBtn}
279
+ onClick={showAllNotifications}
280
+ aria-label="View all notifications"
281
+ type="button"
282
+ style={{
283
+ backgroundColor: 'var(--primary-color)',
284
+ color: 'var(--tertiary-color, white)',
285
+ padding: '10px 20px',
286
+ borderRadius: 'var(--br, 6px)',
287
+ border: 'none',
288
+ width: 'auto',
289
+ minWidth: '200px',
290
+ textAlign: 'center',
291
+ fontWeight: '500',
292
+ fontSize: '14px',
293
+ boxShadow:
294
+ '0 2px 4px rgba(0, 0, 0, 0.1)',
295
+ cursor: 'pointer',
296
+ transition: 'all 0.2s ease',
297
+ }}
298
+ onMouseOver={(e) =>
299
+ (e.target.style.backgroundColor =
300
+ 'var(--secondary-color)')
301
+ }
302
+ onMouseOut={(e) =>
303
+ (e.target.style.backgroundColor =
304
+ 'var(--primary-color)')
305
+ }
306
+ onFocus={(e) => {
307
+ e.target.style.backgroundColor =
308
+ 'var(--primary-color)';
309
+ e.target.style.boxShadow =
310
+ '0 0 0 3px rgba(var(--primary-color-rgb, 0, 86, 179), 0.4)';
311
+ }}
312
+ onBlur={(e) => {
313
+ e.target.style.boxShadow =
314
+ '0 2px 4px rgba(0, 0, 0, 0.1)';
315
+ }}
316
+ >
317
+ View all notifications
187
318
  </button>
188
319
  </div>
189
320
  </div>
190
- )}
321
+ </div>
191
322
  </div>
192
323
  )}
193
- </>
324
+ </div>
194
325
  );
195
326
  }
196
327