@redacto.io/consent-sdk-react 5.1.0 → 5.3.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +24 -1
  3. package/dist/index.d.ts +24 -1
  4. package/dist/index.js +149 -206
  5. package/dist/index.mjs +148 -206
  6. package/dist/privacy-center.d.mts +81 -2
  7. package/dist/privacy-center.d.ts +81 -2
  8. package/dist/privacy-center.js +2823 -1272
  9. package/dist/privacy-center.mjs +2813 -1263
  10. package/package.json +1 -1
  11. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +16 -20
  12. package/src/RedactoNoticeConsent/api/index.ts +21 -103
  13. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +5 -4
  14. package/src/RedactoNoticeConsentInline/api/index.ts +64 -117
  15. package/src/RedactoPrivacyCenter/NotificationsPage.tsx +174 -0
  16. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  17. package/src/RedactoPrivacyCenter/api/actions.ts +84 -0
  18. package/src/RedactoPrivacyCenter/api/client.ts +118 -0
  19. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +15 -0
  20. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +160 -0
  21. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +18 -0
  22. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +54 -0
  23. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +57 -0
  24. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +19 -0
  25. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +92 -0
  26. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +20 -0
  27. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +58 -0
  28. package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +10 -0
  29. package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +112 -0
  30. package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +27 -0
  31. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +3 -2
  32. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  33. package/src/RedactoPrivacyCenter/lib/api.ts +77 -0
  34. package/src/RedactoPrivacyCenter/lib/constants.ts +78 -1
  35. package/src/RedactoPrivacyCenter/lib/types.ts +148 -0
  36. package/src/RedactoPrivacyCenter/lib/utils.ts +3 -0
  37. package/src/RedactoPrivacyCenter/locales/en/translation.json +27 -1
  38. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +505 -0
  39. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +23 -1
  40. package/src/index.ts +5 -0
  41. package/src/privacy-center.ts +1 -0
  42. package/src/shared/api-errors.ts +171 -0
@@ -22,12 +22,20 @@ import type {
22
22
  GetCaseDocumentParams,
23
23
  GetCaseMessagesParams,
24
24
  MessageDocument,
25
+ GetNotificationsServerParams,
26
+ NotificationActionBaseParams,
27
+ NotificationActionResponse,
28
+ NotificationByUuidServerParams,
29
+ NotificationClient,
30
+ NotificationListResponse,
31
+ NotificationSummary,
25
32
  OtpResponse,
26
33
  OtpVerificationResponse,
27
34
  Pagination,
28
35
  SendCaseMessageParams,
29
36
  SubmitDocumentForRequestParams,
30
37
  TokenExchangeResponse,
38
+ UnreadCountResponse,
31
39
  UploadCaseDocumentParams,
32
40
  UploadDocumentResponse,
33
41
  UserConsentDetail,
@@ -474,3 +482,79 @@ export async function getCaseDocumentServer(params: GetCaseDocumentParams): Prom
474
482
  client.getCaseDocument(base, params.caseUuid, params.documentUuid, params.authToken)
475
483
  );
476
484
  }
485
+
486
+ // ─── Notification Hub ───────────────────────────────────────────────────────
487
+
488
+
489
+
490
+ async function runNotificationAction<T>(
491
+ params: NotificationActionBaseParams,
492
+ label: string,
493
+ fn: (client: NotificationClient, baseRequest: BaseRequest) => Promise<T>
494
+ ): Promise<T | null> {
495
+ try {
496
+ if (!params.consentClient) {
497
+ throw new Error('Consent client is required')
498
+ }
499
+ const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
500
+ return await fn(params.consentClient, baseRequest);
501
+ } catch (error) {
502
+ return handleServerActionError(error, label);
503
+ }
504
+ }
505
+
506
+ export async function getNotificationsServer(
507
+ params: GetNotificationsServerParams
508
+ ): Promise<NotificationListResponse | null> {
509
+ return runNotificationAction(params, 'fetching notifications', (client, base) =>
510
+ client.getNotifications(base, {
511
+ category: params.category,
512
+ notification_type: params.notification_type,
513
+ is_read: params.is_read,
514
+ skip: params.skip,
515
+ limit: params.limit,
516
+ include_summary: params.include_summary,
517
+ language: params.language,
518
+ }, params.authToken)
519
+ );
520
+ }
521
+
522
+ export async function getNotificationSummaryServer(
523
+ params: NotificationActionBaseParams
524
+ ): Promise<NotificationSummary | null> {
525
+ return runNotificationAction(params, 'fetching notification summary', (client, base) =>
526
+ client.getNotificationSummary(base, params.authToken)
527
+ );
528
+ }
529
+
530
+ export async function getUnreadCountServer(
531
+ params: NotificationActionBaseParams
532
+ ): Promise<UnreadCountResponse | null> {
533
+ return runNotificationAction(params, 'fetching unread count', (client, base) =>
534
+ client.getUnreadCount(base, params.authToken)
535
+ );
536
+ }
537
+
538
+ export async function markNotificationReadServer(
539
+ params: NotificationByUuidServerParams
540
+ ): Promise<NotificationActionResponse | null> {
541
+ return runNotificationAction(params, 'marking notification as read', (client, base) =>
542
+ client.markNotificationRead(base, params.notificationUuid, params.authToken)
543
+ );
544
+ }
545
+
546
+ export async function markAllNotificationsReadServer(
547
+ params: NotificationActionBaseParams
548
+ ): Promise<NotificationActionResponse | null> {
549
+ return runNotificationAction(params, 'marking all notifications as read', (client, base) =>
550
+ client.markAllNotificationsRead(base, params.authToken)
551
+ );
552
+ }
553
+
554
+ export async function acknowledgeNotificationServer(
555
+ params: NotificationByUuidServerParams
556
+ ): Promise<NotificationActionResponse | null> {
557
+ return runNotificationAction(params, 'acknowledging notification', (client, base) =>
558
+ client.acknowledgeNotification(base, params.notificationUuid, params.authToken)
559
+ );
560
+ }
@@ -3,6 +3,11 @@ import type { ApiFetchOptions, ApiResponse } from "./fetcher";
3
3
  import type {
4
4
  CaseMessage,
5
5
  MessageDocument,
6
+ NotificationActionResponse,
7
+ NotificationListResponse,
8
+ NotificationQueryParams,
9
+ NotificationSummary,
10
+ UnreadCountResponse,
6
11
  UploadDocumentResponse,
7
12
  } from "../lib/types";
8
13
 
@@ -157,6 +162,10 @@ export function createConsentClient(baseUrl: string) {
157
162
  return privacyCenterUrl(baseRequest, `/cases/${caseUuid}${path}`);
158
163
  }
159
164
 
165
+ function notificationUrl(baseRequest: BaseRequest, path = ''): string {
166
+ return privacyCenterUrl(baseRequest, `/notifications${path}`);
167
+ }
168
+
160
169
  function resolveAuthToken(authToken: string | undefined, baseRequest: BaseRequest): string {
161
170
  return authToken || baseRequest.authToken;
162
171
  }
@@ -520,5 +529,114 @@ export function createConsentClient(baseUrl: string) {
520
529
  authToken: resolveAuthToken(authToken, baseRequest),
521
530
  });
522
531
  },
532
+
533
+ // ─── Notification Hub ─────────────────────────────────────────────────
534
+
535
+ /**
536
+ * List notifications (paginated, with filters)
537
+ */
538
+ async getNotifications(
539
+ baseRequest: BaseRequest,
540
+ params?: NotificationQueryParams,
541
+ authToken?: string
542
+ ): Promise<NotificationListResponse> {
543
+ const queryParams = new URLSearchParams();
544
+ if (params?.category) {
545
+ queryParams.append('category', params.category);
546
+ }
547
+ if (params?.notification_type) {
548
+ queryParams.append('notification_type', params.notification_type);
549
+ }
550
+ if (params?.is_read !== undefined) {
551
+ queryParams.append('is_read', String(params.is_read));
552
+ }
553
+ if (params?.skip !== undefined) {
554
+ queryParams.append('skip', params.skip.toString());
555
+ }
556
+ if (params?.limit !== undefined) {
557
+ queryParams.append('limit', params.limit.toString());
558
+ }
559
+ if (params?.include_summary) {
560
+ queryParams.append('include_summary', 'true');
561
+ }
562
+ if (params?.language) {
563
+ queryParams.append('language', params.language);
564
+ }
565
+ const queryString = queryParams.toString();
566
+ const url = notificationUrl(baseRequest, queryString ? `?${queryString}` : '');
567
+ return privacyCenterApiFetch<NotificationListResponse>(url, {
568
+ authToken: resolveAuthToken(authToken, baseRequest),
569
+ });
570
+ },
571
+
572
+ /**
573
+ * Get notification summary (aggregated counts only)
574
+ */
575
+ async getNotificationSummary(
576
+ baseRequest: BaseRequest,
577
+ authToken?: string
578
+ ): Promise<NotificationSummary> {
579
+ const url = notificationUrl(baseRequest, '/summary');
580
+ return privacyCenterApiFetch<NotificationSummary>(url, {
581
+ authToken: resolveAuthToken(authToken, baseRequest),
582
+ });
583
+ },
584
+
585
+ /**
586
+ * Get unread notification count (lightweight, for badge)
587
+ */
588
+ async getUnreadCount(
589
+ baseRequest: BaseRequest,
590
+ authToken?: string
591
+ ): Promise<UnreadCountResponse> {
592
+ const url = notificationUrl(baseRequest, '/unread-count');
593
+ return privacyCenterApiFetch<UnreadCountResponse>(url, {
594
+ authToken: resolveAuthToken(authToken, baseRequest),
595
+ });
596
+ },
597
+
598
+ /**
599
+ * Mark a single notification as read
600
+ */
601
+ async markNotificationRead(
602
+ baseRequest: BaseRequest,
603
+ notificationUuid: string,
604
+ authToken?: string
605
+ ): Promise<NotificationActionResponse> {
606
+ const url = notificationUrl(baseRequest, `/${notificationUuid}/read`);
607
+ return privacyCenterApiFetch<NotificationActionResponse>(url, {
608
+ method: 'PATCH',
609
+ authToken: resolveAuthToken(authToken, baseRequest),
610
+ });
611
+ },
612
+
613
+ /**
614
+ * Mark all notifications as read
615
+ */
616
+ async markAllNotificationsRead(
617
+ baseRequest: BaseRequest,
618
+ authToken?: string
619
+ ): Promise<NotificationActionResponse> {
620
+ const url = notificationUrl(baseRequest, '/mark-all-read');
621
+ return privacyCenterApiFetch<NotificationActionResponse>(url, {
622
+ method: 'POST',
623
+ authToken: resolveAuthToken(authToken, baseRequest),
624
+ });
625
+ },
626
+
627
+ /**
628
+ * Acknowledge a notification (sets is_read and is_acknowledged to true)
629
+ */
630
+ async acknowledgeNotification(
631
+ baseRequest: BaseRequest,
632
+ notificationUuid: string,
633
+ authToken?: string
634
+ ): Promise<NotificationActionResponse> {
635
+ const url = notificationUrl(baseRequest, `/${notificationUuid}/acknowledge`);
636
+ return privacyCenterApiFetch<NotificationActionResponse>(url, {
637
+ method: 'POST',
638
+ authToken: resolveAuthToken(authToken, baseRequest),
639
+ });
640
+ },
523
641
  };
524
642
  }
@@ -258,6 +258,21 @@ export default function PurposeSelectionSection({
258
258
  const actualIndex = item.data_elements.findIndex(
259
259
  (el) => el.uuid === de.uuid
260
260
  );
261
+ if (isErasure) {
262
+ return (
263
+ <p
264
+ key={de.uuid}
265
+ style={{
266
+ fontSize: pcStyles.textSm.fontSize,
267
+ color: getVar('--pc-text-primary'),
268
+ textTransform: 'capitalize',
269
+ margin: 0,
270
+ }}
271
+ >
272
+ {de.name}
273
+ </p>
274
+ );
275
+ }
261
276
  return (
262
277
  <PCCheckbox
263
278
  key={de.uuid}
@@ -0,0 +1,160 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { IoNotificationsOutline } from 'react-icons/io5';
4
+ import { PCPopover } from '../../ui';
5
+ import { PCTooltip } from '../../ui/PCTooltip';
6
+ import { fetchUnreadCount, fetchNotifications } from '../../lib/api';
7
+ import { POLL_INTERVAL, NOTIFICATION_DROPDOWN_LIMIT } from '../../lib/constants';
8
+ import { useCurrentLanguage } from '../../lib/useCurrentLanguage';
9
+ import type { Notification, NotificationCTA } from '../../lib/types';
10
+ import { NotificationDropdown } from './NotificationDropdown';
11
+ import { useNotificationActions } from './useNotificationActions';
12
+
13
+ export const NotificationBell = () => {
14
+ const { t } = useTranslation();
15
+ const { langParam } = useCurrentLanguage();
16
+ const [notifications, setNotifications] = useState<Notification[]>([]);
17
+ const [unreadCount, setUnreadCount] = useState(0);
18
+ const [isRefreshing, setIsRefreshing] = useState(false);
19
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
20
+ const tenantRequestRef = useRef(0);
21
+
22
+ const {
23
+ organizationId, workspaceId, authContext, consentClient, navigateTo,
24
+ handleMarkRead, handleMarkAllRead, handleCTAClick,
25
+ } = useNotificationActions();
26
+
27
+ const pollUnreadCount = useCallback(async () => {
28
+ if (!organizationId || !workspaceId) {
29
+ return;
30
+ }
31
+ const requestId = tenantRequestRef.current;
32
+ const result = await fetchUnreadCount(organizationId, workspaceId, authContext, consentClient);
33
+ if (requestId !== tenantRequestRef.current) {
34
+ return;
35
+ }
36
+ if (result) {
37
+ setUnreadCount(result.count);
38
+ }
39
+ }, [organizationId, workspaceId, authContext, consentClient]);
40
+
41
+ const loadDropdownNotifications = useCallback(async () => {
42
+ if (!organizationId || !workspaceId) {
43
+ return;
44
+ }
45
+ const requestId = tenantRequestRef.current;
46
+ const result = await fetchNotifications(organizationId, workspaceId, { limit: NOTIFICATION_DROPDOWN_LIMIT, include_summary: true }, authContext, consentClient, langParam);
47
+ if (requestId !== tenantRequestRef.current) {
48
+ return;
49
+ }
50
+ if (result) {
51
+ setNotifications(result.data);
52
+ if (result.summary) {
53
+ setUnreadCount(result.summary.unread);
54
+ }
55
+ }
56
+ }, [organizationId, workspaceId, authContext, consentClient, langParam]);
57
+
58
+ useEffect(() => {
59
+ tenantRequestRef.current += 1;
60
+ setNotifications([]);
61
+ setUnreadCount(0);
62
+ return () => {
63
+ tenantRequestRef.current += 1;
64
+ };
65
+ }, [organizationId, workspaceId]);
66
+
67
+ useEffect(() => {
68
+ if (!organizationId || !workspaceId) {
69
+ if (pollRef.current) {
70
+ clearInterval(pollRef.current);
71
+ pollRef.current = null;
72
+ }
73
+ return;
74
+ }
75
+ pollUnreadCount();
76
+ pollRef.current = setInterval(() => pollUnreadCount(), POLL_INTERVAL);
77
+ return () => {
78
+ if (pollRef.current) {
79
+ clearInterval(pollRef.current);
80
+ }
81
+ };
82
+ }, [pollUnreadCount, organizationId, workspaceId]);
83
+
84
+ const onRefresh = useCallback(async () => {
85
+ setIsRefreshing(true);
86
+ try {
87
+ await loadDropdownNotifications();
88
+ } finally {
89
+ setIsRefreshing(false);
90
+ }
91
+ }, [loadDropdownNotifications]);
92
+
93
+ const onMarkAllRead = useCallback(async () => {
94
+ const ok = await handleMarkAllRead();
95
+ if (!ok) {
96
+ return;
97
+ }
98
+ setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
99
+ setUnreadCount(0);
100
+ }, [handleMarkAllRead]);
101
+
102
+ const onNotificationClick = useCallback(async (notification: Notification) => {
103
+ if (!notification.is_read) {
104
+ const ok = await handleMarkRead(notification.uuid);
105
+ if (!ok) {
106
+ return;
107
+ }
108
+ setNotifications((prev) => prev.map((n) => n.uuid === notification.uuid ? { ...n, is_read: true } : n));
109
+ setUnreadCount((prev) => Math.max(0, prev - 1));
110
+ }
111
+ }, [handleMarkRead]);
112
+
113
+ const onCTAClick = useCallback(async (notification: Notification, cta: NotificationCTA) => {
114
+ const result = await handleCTAClick(notification, cta);
115
+ if (result === 'acknowledged') {
116
+ setNotifications((prev) => prev.map((n) =>
117
+ n.uuid === notification.uuid ? { ...n, is_read: true, is_acknowledged: true } : n
118
+ ));
119
+ setUnreadCount((prev) => Math.max(0, prev - (notification.is_read ? 0 : 1)));
120
+ } else if (result === 'opened' || result === 'navigated' || result === 'not_found') {
121
+ if (!notification.is_read) {
122
+ setNotifications((prev) => prev.map((n) =>
123
+ n.uuid === notification.uuid ? { ...n, is_read: true } : n
124
+ ));
125
+ setUnreadCount((prev) => Math.max(0, prev - 1));
126
+ }
127
+ }
128
+ }, [handleCTAClick]);
129
+
130
+ return (
131
+ <PCPopover
132
+ placement="bottom-end"
133
+ shadow="lg"
134
+ className="pc-notification-popover-offset"
135
+ onOpen={loadDropdownNotifications}
136
+ content={
137
+ <NotificationDropdown
138
+ notifications={notifications}
139
+ onMarkAllRead={onMarkAllRead}
140
+ onRefresh={onRefresh}
141
+ isRefreshing={isRefreshing}
142
+ onViewAll={() => navigateTo('notifications')}
143
+ onNotificationClick={onNotificationClick}
144
+ onCTAClick={onCTAClick}
145
+ />
146
+ }
147
+ >
148
+ <PCTooltip content={t('notifications')} placement="bottom" color="invert">
149
+ <div className="pc-notification-bell-wrapper">
150
+ <button type="button" aria-label={t('notifications')} className="pc-notification-bell-btn">
151
+ <IoNotificationsOutline size={20} />
152
+ </button>
153
+ {unreadCount > 0 && (
154
+ <span className="pc-notification-badge">{unreadCount}</span>
155
+ )}
156
+ </div>
157
+ </PCTooltip>
158
+ </PCPopover>
159
+ );
160
+ }
@@ -0,0 +1,18 @@
1
+ import type { NotificationCTAButtonProps } from '../../lib/types';
2
+ import { CTA_VARIANT_CLASS } from '../../lib/constants';
3
+ import { cn } from '../../utils/cn';
4
+
5
+ export const NotificationCTAButton = ({ cta, onClick, size = 'lg' }: NotificationCTAButtonProps) => {
6
+ const sizeClass = size === 'sm' ? 'pc-notification-cta-sm' : 'pc-notification-cta-lg';
7
+ const variantClass = CTA_VARIANT_CLASS[cta.style] ?? CTA_VARIANT_CLASS.secondary;
8
+
9
+ return (
10
+ <button
11
+ type='button'
12
+ onClick={onClick}
13
+ className={cn('pc-notification-cta', sizeClass, variantClass)}
14
+ >
15
+ {cta.label}
16
+ </button>
17
+ );
18
+ }
@@ -0,0 +1,54 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import type { NotificationDropdownProps } from '../../lib/types';
3
+ import { NOTIFICATION_DROPDOWN_LIMIT } from '../../lib/constants';
4
+ import { NotificationDropdownItem } from './NotificationDropdownItem';
5
+ import { NotificationRefreshButton } from './NotificationRefreshButton';
6
+
7
+ export const NotificationDropdown = ({
8
+ notifications, onMarkAllRead, onRefresh, isRefreshing, onViewAll, onNotificationClick, onCTAClick,
9
+ }: NotificationDropdownProps) => {
10
+ const { t } = useTranslation();
11
+ const recentNotifications = notifications.slice(0, NOTIFICATION_DROPDOWN_LIMIT);
12
+
13
+ return (
14
+ <div className="pc-notification-dropdown">
15
+ {/* Header */}
16
+ <div className="pc-notification-panel-header">
17
+ <span className="pc-notification-dropdown-title">{t('notifications')}</span>
18
+ <div className="pc-notification-header-actions">
19
+ <NotificationRefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} iconSize={16} />
20
+ <button type="button" onClick={onMarkAllRead} className="pc-notification-link-btn">
21
+ {t('markAllAsRead')}
22
+ </button>
23
+ </div>
24
+ </div>
25
+
26
+ {/* Notification List */}
27
+ <div className="pc-notification-dropdown-list">
28
+ {recentNotifications.length === 0 ? (
29
+ <div className="pc-notification-dropdown-empty-state">{t('noNotificationsYet')}</div>
30
+ ) : (
31
+ recentNotifications.map((notification) => (
32
+ <NotificationDropdownItem
33
+ key={notification.uuid}
34
+ notification={notification}
35
+ onNotificationClick={onNotificationClick}
36
+ onCTAClick={onCTAClick}
37
+ />
38
+ ))
39
+ )}
40
+ </div>
41
+
42
+ {/* Footer */}
43
+ <div className="pc-notification-dropdown-footer">
44
+ <button
45
+ type="button"
46
+ onClick={onViewAll}
47
+ className="pc-notification-link-btn pc-notification-link-btn-arrow"
48
+ >
49
+ {t('viewAllNotifications')} →
50
+ </button>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,57 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import type { NotificationDropdownItemProps } from '../../lib/types';
3
+ import { NotificationIcon } from './NotificationIcon';
4
+ import { NotificationCTAButton } from './NotificationCTAButton';
5
+ import { formatTimeAgo } from './utils';
6
+
7
+ export const NotificationDropdownItem = ({
8
+ notification, onNotificationClick, onCTAClick,
9
+ }: NotificationDropdownItemProps) => {
10
+ const { t } = useTranslation();
11
+ const statusLabel = notification.status_label_display || notification.status_label;
12
+
13
+ return (
14
+ <div
15
+ className="pc-notification-dropdown-row"
16
+ data-read={notification.is_read ? 'true' : 'false'}
17
+ role="button"
18
+ tabIndex={0}
19
+ onClick={() => onNotificationClick(notification)}
20
+ onKeyDown={(e) => {
21
+ if (e.currentTarget !== e.target) {
22
+ return;
23
+ }
24
+ if (e.key === 'Enter' || e.key === ' ') {
25
+ e.preventDefault();
26
+ onNotificationClick(notification);
27
+ }
28
+ }}
29
+ >
30
+ <NotificationIcon notificationType={notification.notification_type} size="sm" />
31
+
32
+ <div className="pc-notification-dropdown-content">
33
+ <p className="pc-notification-dropdown-title-text">{notification.title}</p>
34
+ {statusLabel && (
35
+ <span className="pc-notification-status-badge">{statusLabel}</span>
36
+ )}
37
+ <p className="pc-notification-dropdown-description">{notification.description}</p>
38
+ <p className="pc-notification-dropdown-time">{formatTimeAgo(notification.created_at, t)}</p>
39
+
40
+ {notification.ctas && notification.ctas.length > 0 && (
41
+ <div className="pc-notification-dropdown-ctas">
42
+ {notification.ctas.map((cta) => (
43
+ <NotificationCTAButton
44
+ key={cta.label}
45
+ cta={cta}
46
+ size="sm"
47
+ onClick={(e) => { e.stopPropagation(); onCTAClick(notification, cta); }}
48
+ />
49
+ ))}
50
+ </div>
51
+ )}
52
+ </div>
53
+
54
+ {!notification.is_read && <div className="pc-notification-unread-dot-sm" />}
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,19 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type { NotificationIconProps } from '../../lib/types';
3
+ import { TYPE_TO_ICON_CATEGORY, ICON_MAP, ICON_COLORS } from '../../lib/constants';
4
+ import { cn } from '../../utils/cn';
5
+
6
+ export const NotificationIcon = ({ notificationType, size = 'lg' }: NotificationIconProps) => {
7
+ const category = TYPE_TO_ICON_CATEGORY[notificationType] ?? 'system';
8
+ const Icon = ICON_MAP[category];
9
+ const color = ICON_COLORS[category];
10
+ const sizeClass = size === 'sm' ? 'pc-notification-icon-sm' : 'pc-notification-icon-lg';
11
+ const iconPx = size === 'sm' ? 18 : 20;
12
+ const iconBgStyle = { '--pc-notification-icon-bg': `${color}14` } as CSSProperties;
13
+
14
+ return (
15
+ <div className={cn('pc-notification-icon', sizeClass)} style={iconBgStyle}>
16
+ <Icon size={iconPx} color={color} />
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,92 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import type { NotificationListPanelProps } from '../../lib/types';
3
+ import { useNotificationFilterTabs, NOTIFICATION_PAGE_SIZE } from '../../lib/constants';
4
+ import { NotificationRow } from './NotificationRow';
5
+ import { NotificationRefreshButton } from './NotificationRefreshButton';
6
+
7
+
8
+ export const NotificationListPanel = ({
9
+ notifications, totalCount, activeTab, onTabChange, skip, onSkipChange,
10
+ isLoading, isRefreshing, onRefresh, onMarkAllRead, onNotificationClick, onCTAClick,
11
+ }: NotificationListPanelProps) => {
12
+ const { t } = useTranslation();
13
+ const tabs = useNotificationFilterTabs();
14
+ const hasMore = skip + NOTIFICATION_PAGE_SIZE < totalCount;
15
+
16
+ return (
17
+ <div className="pc-notification-panel">
18
+ {/* Header */}
19
+ <div className="pc-notification-panel-header">
20
+ <h2 className="pc-notification-panel-title">
21
+ {t('notifications')}
22
+ <span className="pc-notification-panel-count">({totalCount})</span>
23
+ </h2>
24
+ <div className="pc-notification-header-actions">
25
+ <NotificationRefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} iconSize={18} />
26
+ <button type="button" onClick={onMarkAllRead} className="pc-notification-link-btn">
27
+ {t('markAllAsRead')}
28
+ </button>
29
+ </div>
30
+ </div>
31
+
32
+ {/* Filter tabs */}
33
+ <div className="pc-notification-filter-tabs">
34
+ {tabs.map((tab) => (
35
+ <button
36
+ key={tab.key}
37
+ type="button"
38
+ onClick={() => onTabChange(tab.key)}
39
+ className="pc-notification-filter-tab"
40
+ data-active={activeTab === tab.key ? 'true' : 'false'}
41
+ aria-pressed={activeTab === tab.key}
42
+ >
43
+ {tab.label}
44
+ </button>
45
+ ))}
46
+ </div>
47
+
48
+ {/* Notification list */}
49
+ <div>
50
+ {isLoading && !isRefreshing ? (
51
+ <div className="pc-notification-empty-state">{t('loadingNotifications')}</div>
52
+ ) : notifications.length === 0 ? (
53
+ <div className="pc-notification-empty-state">{t('noNotificationsInCategory')}</div>
54
+ ) : (
55
+ notifications.map((notification) => (
56
+ <NotificationRow
57
+ key={notification.uuid}
58
+ notification={notification}
59
+ onClick={() => onNotificationClick(notification)}
60
+ onCTAClick={(cta) => onCTAClick(notification, cta)}
61
+ />
62
+ ))
63
+ )}
64
+ </div>
65
+
66
+ {/* Pagination */}
67
+ {totalCount > NOTIFICATION_PAGE_SIZE && (
68
+ <div className="pc-notification-pagination">
69
+ <button
70
+ type="button"
71
+ onClick={() => onSkipChange(Math.max(0, skip - NOTIFICATION_PAGE_SIZE))}
72
+ disabled={skip === 0}
73
+ className="pc-notification-pagination-btn"
74
+ >
75
+ {t('previous')}
76
+ </button>
77
+ <span className="pc-notification-pagination-range">
78
+ {t('paginationRange', { start: skip + 1, end: Math.min(skip + NOTIFICATION_PAGE_SIZE, totalCount), total: totalCount })}
79
+ </span>
80
+ <button
81
+ type="button"
82
+ onClick={() => onSkipChange(skip + NOTIFICATION_PAGE_SIZE)}
83
+ disabled={!hasMore}
84
+ className="pc-notification-pagination-btn"
85
+ >
86
+ {t('next')}
87
+ </button>
88
+ </div>
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,20 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { IoRefreshOutline } from 'react-icons/io5';
3
+ import { cn } from '../../utils/cn';
4
+ import type { NotificationRefreshButtonProps } from '../../lib/types';
5
+
6
+ export const NotificationRefreshButton = ({ isRefreshing, onRefresh, iconSize = 16 }: NotificationRefreshButtonProps) => {
7
+ const { t } = useTranslation();
8
+ return (
9
+ <button
10
+ type="button"
11
+ onClick={onRefresh}
12
+ disabled={isRefreshing}
13
+ title={t('refreshNotifications')}
14
+ aria-label={t('refreshNotifications')}
15
+ className={cn('pc-notification-refresh-btn', isRefreshing && 'pc-notification-refresh-btn--spinning')}
16
+ >
17
+ <IoRefreshOutline size={iconSize} />
18
+ </button>
19
+ );
20
+ };