@redacto.io/consent-sdk-react 5.2.0 → 6.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.
Files changed (84) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/CHANGELOG.md +12 -0
  3. package/dist/index.d.mts +24 -1
  4. package/dist/index.d.ts +24 -1
  5. package/dist/index.js +149 -206
  6. package/dist/index.mjs +148 -206
  7. package/dist/privacy-center.d.mts +178 -39
  8. package/dist/privacy-center.d.ts +178 -39
  9. package/dist/privacy-center.js +10534 -6795
  10. package/dist/privacy-center.mjs +7922 -4181
  11. package/package.json +1 -1
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +16 -20
  13. package/src/RedactoNoticeConsent/api/index.ts +21 -103
  14. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +5 -4
  15. package/src/RedactoNoticeConsentInline/api/index.ts +64 -117
  16. package/src/RedactoPrivacyCenter/NotificationsPage.tsx +174 -0
  17. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  18. package/src/RedactoPrivacyCenter/api/actions.ts +136 -8
  19. package/src/RedactoPrivacyCenter/api/client.ts +176 -15
  20. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +722 -273
  21. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +187 -0
  22. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +65 -165
  23. package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +179 -0
  24. package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +318 -0
  25. package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +333 -0
  26. package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +102 -0
  27. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +6 -2
  28. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +15 -0
  29. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +8 -5
  30. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +160 -0
  31. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +18 -0
  32. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +54 -0
  33. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +57 -0
  34. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +19 -0
  35. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +92 -0
  36. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +20 -0
  37. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +58 -0
  38. package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +10 -0
  39. package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +112 -0
  40. package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +27 -0
  41. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +105 -55
  42. package/src/RedactoPrivacyCenter/components/types.ts +2 -5
  43. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  44. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +21 -1
  45. package/src/RedactoPrivacyCenter/lib/api.ts +147 -28
  46. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +4 -2
  47. package/src/RedactoPrivacyCenter/lib/constants.ts +78 -1
  48. package/src/RedactoPrivacyCenter/lib/types.ts +205 -36
  49. package/src/RedactoPrivacyCenter/lib/utils.ts +3 -0
  50. package/src/RedactoPrivacyCenter/locales/as/translation.json +12 -1
  51. package/src/RedactoPrivacyCenter/locales/bho/translation.json +12 -1
  52. package/src/RedactoPrivacyCenter/locales/bn/translation.json +12 -1
  53. package/src/RedactoPrivacyCenter/locales/brx/translation.json +12 -1
  54. package/src/RedactoPrivacyCenter/locales/doi/translation.json +12 -1
  55. package/src/RedactoPrivacyCenter/locales/en/translation.json +42 -1
  56. package/src/RedactoPrivacyCenter/locales/gom/translation.json +12 -1
  57. package/src/RedactoPrivacyCenter/locales/gu/translation.json +12 -1
  58. package/src/RedactoPrivacyCenter/locales/hi/translation.json +15 -1
  59. package/src/RedactoPrivacyCenter/locales/kn/translation.json +12 -1
  60. package/src/RedactoPrivacyCenter/locales/ks/translation.json +12 -1
  61. package/src/RedactoPrivacyCenter/locales/mai/translation.json +12 -1
  62. package/src/RedactoPrivacyCenter/locales/ml/translation.json +12 -1
  63. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +12 -1
  64. package/src/RedactoPrivacyCenter/locales/mr/translation.json +12 -1
  65. package/src/RedactoPrivacyCenter/locales/ne/translation.json +12 -1
  66. package/src/RedactoPrivacyCenter/locales/or/translation.json +12 -1
  67. package/src/RedactoPrivacyCenter/locales/pa/translation.json +12 -1
  68. package/src/RedactoPrivacyCenter/locales/sa/translation.json +12 -1
  69. package/src/RedactoPrivacyCenter/locales/sat/translation.json +12 -1
  70. package/src/RedactoPrivacyCenter/locales/sd/translation.json +12 -1
  71. package/src/RedactoPrivacyCenter/locales/ta/translation.json +12 -1
  72. package/src/RedactoPrivacyCenter/locales/te/translation.json +12 -1
  73. package/src/RedactoPrivacyCenter/locales/ur/translation.json +12 -1
  74. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1027 -0
  75. package/src/RedactoPrivacyCenter/styles/variables.css +5 -0
  76. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +36 -16
  77. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +4 -12
  78. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +1 -1
  79. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +23 -1
  80. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +51 -5
  81. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +2 -4
  82. package/src/index.ts +5 -0
  83. package/src/privacy-center.ts +8 -3
  84. package/src/shared/api-errors.ts +171 -0
@@ -0,0 +1,102 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { PCEmpty, PCText } from '../ui';
3
+ import type { ConsentModalRecord, DisplayPurposeRow } from '../lib/types';
4
+ import {
5
+ renderGivenDate,
6
+ renderMethod,
7
+ renderModifyButton,
8
+ renderStatusBadge,
9
+ renderValidTill,
10
+ } from './ConsentManagerCells';
11
+
12
+ type ConsentManagerPurposeCardsProps = {
13
+ rows: DisplayPurposeRow[];
14
+ onModify: (record: ConsentModalRecord) => void;
15
+ };
16
+
17
+ const toModalRecord = (row: DisplayPurposeRow): ConsentModalRecord => ({
18
+ ...row,
19
+ });
20
+
21
+ export function ConsentManagerPurposeCards({
22
+ rows,
23
+ onModify,
24
+ }: ConsentManagerPurposeCardsProps) {
25
+ const { t } = useTranslation();
26
+
27
+ if (rows.length === 0) {
28
+ return (
29
+ <div className="pc-consent-mobile-empty">
30
+ <PCEmpty title={t('noData')} />
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <div className="pc-consent-mobile-list">
37
+ {rows.map((row) => (
38
+ <article
39
+ key={`${row.product_uuid}:${row.purpose_uuid}`}
40
+ className="pc-consent-mobile-card"
41
+ >
42
+ <div className="pc-consent-mobile-purpose-head">
43
+ <div style={{ minWidth: 0 }}>
44
+ <PCText size="sm" weight="semibold">{row.name}</PCText>
45
+ {row.description && (
46
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-subtle-text">
47
+ {row.description}
48
+ </PCText>
49
+ )}
50
+ </div>
51
+ {renderStatusBadge(row.status, t)}
52
+ </div>
53
+
54
+ <div className="pc-consent-mobile-activity-row">
55
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
56
+ {t('product')}
57
+ </PCText>
58
+ <PCText size="sm" weight="medium">{row.product_name || '-'}</PCText>
59
+ </div>
60
+
61
+ {row.nominator && (
62
+ <div className="pc-consent-mobile-activity-row">
63
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
64
+ {t('nominatedBy')}
65
+ </PCText>
66
+ <PCText size="sm" weight="medium">
67
+ {row.nominator.name || row.nominator.email || row.nominator.org_user_id}
68
+ </PCText>
69
+ </div>
70
+ )}
71
+
72
+ <div className="pc-consent-mobile-meta-grid">
73
+ <div>
74
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
75
+ {t('givenDate')}
76
+ </PCText>
77
+ {renderGivenDate(row.given_date, { showTooltip: false })}
78
+ </div>
79
+ <div>
80
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
81
+ {t('validTill')}
82
+ </PCText>
83
+ {renderValidTill(row, t)}
84
+ </div>
85
+ <div>
86
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
87
+ {t('method')}
88
+ </PCText>
89
+ {renderMethod(row.method)}
90
+ </div>
91
+ <div className="pc-consent-mobile-action-cell">
92
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
93
+ {t('action')}
94
+ </PCText>
95
+ {renderModifyButton(toModalRecord(row), onModify, t)}
96
+ </div>
97
+ </div>
98
+ </article>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
@@ -6,8 +6,9 @@ const getVar = (varName: string): string => `var(${varName})`;
6
6
  export const ConsentManagerTableSkeleton = () => {
7
7
  return (
8
8
  <div style={{
9
- borderRadius: getVar('--pc-border-radius-md'),
9
+ borderRadius: `${getVar('--pc-border-radius-lg')} ${getVar('--pc-border-radius-lg')} 0 0`,
10
10
  border: `1px solid ${getVar('--pc-border-color')}`,
11
+ borderBottom: 'none',
11
12
  backgroundColor: getVar('--pc-bg-primary'),
12
13
  }}>
13
14
  {/* Table Header */}
@@ -44,7 +45,10 @@ export const ConsentManagerTableSkeleton = () => {
44
45
  <div
45
46
  key={index}
46
47
  style={{
47
- borderBottom: `1px solid ${getVar('--pc-border-color')}`,
48
+ borderBottom:
49
+ index === SKELETON_ROW_COUNT - 1
50
+ ? 'none'
51
+ : `1px solid ${getVar('--pc-border-color')}`,
48
52
  backgroundColor: getVar('--pc-bg-primary'),
49
53
  }}
50
54
  >
@@ -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}
@@ -19,7 +19,7 @@ export const ModifyConsentModal = ({
19
19
  const [isAccordionOpen, setIsAccordionOpen] = useState(false);
20
20
  const [selectedDataElementUuids, setSelectedDataElementUuids] = useState<string[]>([]);
21
21
 
22
- const nominatorInfo = selectedRecord?.nominator_info;
22
+ const nominatorInfo = selectedRecord?.nominator;
23
23
  const nominatorLabel = nominatorInfo?.name || nominatorInfo?.email || nominatorInfo?.org_user_id || null;
24
24
 
25
25
  const actionConfig = useMemo(() => {
@@ -39,7 +39,10 @@ export const ModifyConsentModal = ({
39
39
  };
40
40
  }
41
41
 
42
- if (status === ConsentStatusEnum.WITHDRAWN) {
42
+ if (
43
+ status === ConsentStatusEnum.WITHDRAWN ||
44
+ status === ConsentStatusEnum.REVOKED
45
+ ) {
43
46
  return {
44
47
  action: 'regrant' as ConsentAction,
45
48
  buttonLabel: t('regrantConsent'),
@@ -158,7 +161,7 @@ export const ModifyConsentModal = ({
158
161
  fontWeight: pcStyles.fontWeightSemibold.fontWeight,
159
162
  color: pcStyles.textPrimary.color,
160
163
  }}>
161
- {selectedRecord.purpose}
164
+ {selectedRecord.name}
162
165
  </p>
163
166
  <p style={{
164
167
  textAlign: 'left',
@@ -167,7 +170,7 @@ export const ModifyConsentModal = ({
167
170
  color: pcStyles.textSecondary.color,
168
171
  margin: 0,
169
172
  }}>
170
- {selectedRecord.purpose_description}
173
+ {selectedRecord.description}
171
174
  </p>
172
175
  </div>
173
176
  <ChevronDownIcon
@@ -309,7 +312,7 @@ export const ModifyConsentModal = ({
309
312
  variant={actionConfig.buttonVariant}
310
313
  onClick={() => {
311
314
  if (selectedRecord.purpose_uuid) {
312
- const nomContact = selectedRecord.nominator_info?.email || selectedRecord.nominator_info?.org_user_id || undefined;
315
+ const nomContact = selectedRecord.nominator?.email || selectedRecord.nominator?.org_user_id || undefined;
313
316
  onManageConsent(
314
317
  selectedRecord.purpose_uuid,
315
318
  actionConfig.action,
@@ -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={18} />
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
+ };