@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.
- package/.turbo/turbo-build.log +24 -0
- package/CHANGELOG.md +12 -0
- package/dist/index.d.mts +24 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +149 -206
- package/dist/index.mjs +148 -206
- package/dist/privacy-center.d.mts +178 -39
- package/dist/privacy-center.d.ts +178 -39
- package/dist/privacy-center.js +10534 -6795
- package/dist/privacy-center.mjs +7922 -4181
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +16 -20
- package/src/RedactoNoticeConsent/api/index.ts +21 -103
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +5 -4
- package/src/RedactoNoticeConsentInline/api/index.ts +64 -117
- package/src/RedactoPrivacyCenter/NotificationsPage.tsx +174 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
- package/src/RedactoPrivacyCenter/api/actions.ts +136 -8
- package/src/RedactoPrivacyCenter/api/client.ts +176 -15
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +722 -273
- package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +187 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +65 -165
- package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +179 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +318 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +333 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +102 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +6 -2
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +15 -0
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +8 -5
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +160 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +18 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +54 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +57 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +19 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +92 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +20 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +58 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +10 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +112 -0
- package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +27 -0
- package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +105 -55
- package/src/RedactoPrivacyCenter/components/types.ts +2 -5
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
- package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +21 -1
- package/src/RedactoPrivacyCenter/lib/api.ts +147 -28
- package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +4 -2
- package/src/RedactoPrivacyCenter/lib/constants.ts +78 -1
- package/src/RedactoPrivacyCenter/lib/types.ts +205 -36
- package/src/RedactoPrivacyCenter/lib/utils.ts +3 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +42 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +15 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +12 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +12 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1027 -0
- package/src/RedactoPrivacyCenter/styles/variables.css +5 -0
- package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +36 -16
- package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +4 -12
- package/src/RedactoPrivacyCenter/ui/PCInput.tsx +1 -1
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +23 -1
- package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +51 -5
- package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +2 -4
- package/src/index.ts +5 -0
- package/src/privacy-center.ts +8 -3
- 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-
|
|
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:
|
|
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?.
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
};
|