@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.
- 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 +81 -2
- package/dist/privacy-center.d.ts +81 -2
- package/dist/privacy-center.js +2823 -1272
- package/dist/privacy-center.mjs +2813 -1263
- 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 +84 -0
- package/src/RedactoPrivacyCenter/api/client.ts +118 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +15 -0
- 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 +3 -2
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
- package/src/RedactoPrivacyCenter/lib/api.ts +77 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +78 -1
- package/src/RedactoPrivacyCenter/lib/types.ts +148 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +3 -0
- package/src/RedactoPrivacyCenter/locales/en/translation.json +27 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +505 -0
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +23 -1
- package/src/index.ts +5 -0
- package/src/privacy-center.ts +1 -0
- 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
|
+
};
|