@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "5.2.0",
3
+ "version": "6.0.0",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -2168,11 +2168,12 @@ export const RedactoNoticeConsent = ({
2168
2168
  return;
2169
2169
  }
2170
2170
 
2171
- const err = error as Error & { status?: number };
2171
+ const err = error as Error;
2172
+ // The SDK is a passthrough: server-owned message wins. Only the
2173
+ // bare-Error fallback is used when no message is present (e.g. a
2174
+ // network failure that never produced a Response).
2172
2175
  const message =
2173
- err.status === 500
2174
- ? "An error occurred while submitting your consent. Please try again later."
2175
- : err.message || "Failed to submit consent. Please try again.";
2176
+ err.message || "Failed to submit consent. Please try again.";
2176
2177
  setErrorMessage(message);
2177
2178
  onError?.(error as Error);
2178
2179
  } finally {
@@ -2713,30 +2714,25 @@ export const RedactoNoticeConsent = ({
2713
2714
  const availableLanguages = useMemo(() => {
2714
2715
  if (!content) return [language];
2715
2716
 
2716
- // English is always available since it's in active_config
2717
- const languages = ["English"];
2718
-
2719
- // Add default language if it's not English
2717
+ // English is always pinned first since it's in active_config.
2718
+ // Non-English default_language is pinned second so users can find it quickly.
2719
+ // Remaining languages are sorted A→Z by English name.
2720
+ const pinned = ["English"];
2720
2721
  if (
2721
2722
  content.default_language &&
2722
2723
  content.default_language !== "English" &&
2723
2724
  content.default_language !== "en"
2724
2725
  ) {
2725
- languages.push(content.default_language);
2726
+ pinned.push(content.default_language);
2726
2727
  }
2727
2728
 
2728
- // Add all supported languages from translations
2729
- if (content.supported_languages_and_translations) {
2730
- Object.keys(content.supported_languages_and_translations).forEach(
2731
- (lang) => {
2732
- if (!languages.includes(lang)) {
2733
- languages.push(lang);
2734
- }
2735
- }
2736
- );
2737
- }
2729
+ const rest = content.supported_languages_and_translations
2730
+ ? Object.keys(content.supported_languages_and_translations)
2731
+ .filter((lang) => !pinned.includes(lang))
2732
+ .sort((a, b) => a.localeCompare(b, "en"))
2733
+ : [];
2738
2734
 
2739
- return languages;
2735
+ return [...pinned, ...rest];
2740
2736
  }, [content, language]);
2741
2737
 
2742
2738
  /**
@@ -1,4 +1,5 @@
1
1
  import { jwtDecode } from "jwt-decode";
2
+ import { parseApiError } from "../../shared/api-errors";
2
3
  import type {
3
4
  ConsentContent,
4
5
  ConsentEventPayload,
@@ -109,23 +110,10 @@ export const fetchConsentContent = async ({
109
110
  });
110
111
 
111
112
  if (!response.ok) {
112
- let errorMessage = `Failed to fetch consent content: ${response.status} ${response.statusText}`;
113
-
114
- if (response.status === 401) {
115
- errorMessage = "Unauthorized: Invalid or expired token";
116
- } else if (response.status === 403) {
117
- errorMessage = "Forbidden: Access denied";
118
- } else if (response.status === 404) {
119
- errorMessage = "Notice not found";
120
- } else if (response.status === 409) {
121
- errorMessage = "User has already provided consent";
122
- } else if (response.status >= 500) {
123
- errorMessage = "Server error: Please try again later";
124
- }
125
-
126
- const error = new Error(errorMessage) as Error & { status?: number };
127
- error.status = response.status;
128
- throw error;
113
+ throw await parseApiError(
114
+ response,
115
+ "Could not load the consent notice. Please try again."
116
+ );
129
117
  }
130
118
 
131
119
  const data: ConsentContent = await response.json();
@@ -211,28 +199,10 @@ export const submitConsentEvent = async ({
211
199
  );
212
200
 
213
201
  if (!response.ok) {
214
- let errorMessage = `Failed to submit consent: ${response.status} ${response.statusText}`;
215
-
216
- if (response.status === 400) {
217
- try {
218
- const errorData = await response.json();
219
- errorMessage = errorData.message || "Invalid request data";
220
- } catch {
221
- errorMessage = "Invalid request data";
222
- }
223
- } else if (response.status === 401) {
224
- errorMessage = "Unauthorized: Invalid or expired token";
225
- } else if (response.status === 403) {
226
- errorMessage = "Forbidden: Access denied";
227
- } else if (response.status === 404) {
228
- errorMessage = "Notice not found";
229
- } else if (response.status >= 500) {
230
- errorMessage = "Server error: Please try again later";
231
- }
232
-
233
- const error = new Error(errorMessage) as Error & { status?: number };
234
- error.status = response.status;
235
- throw error;
202
+ throw await parseApiError(
203
+ response,
204
+ "Could not submit your consent. Please try again."
205
+ );
236
206
  }
237
207
 
238
208
  // Clear any cached consent content since user preferences have changed
@@ -274,21 +244,10 @@ export const fetchTTSAudioUrls = async ({
274
244
  });
275
245
 
276
246
  if (!response.ok) {
277
- let errorMessage = `Failed to fetch TTS audio URLs: ${response.status} ${response.statusText}`;
278
-
279
- if (response.status === 401) {
280
- errorMessage = "Unauthorized: Invalid or expired token";
281
- } else if (response.status === 403) {
282
- errorMessage = "Forbidden: Access denied";
283
- } else if (response.status === 404) {
284
- errorMessage = "Notice or audio not found";
285
- } else if (response.status >= 500) {
286
- errorMessage = "Server error: Please try again later";
287
- }
288
-
289
- const error = new Error(errorMessage) as Error & { status?: number };
290
- error.status = response.status;
291
- throw error;
247
+ throw await parseApiError(
248
+ response,
249
+ "Could not load audio narration. Please try again."
250
+ );
292
251
  }
293
252
 
294
253
  const data: TTSAudioUrlsResponse = await response.json();
@@ -378,33 +337,10 @@ export const initiateGuardianVerification = async ({
378
337
  );
379
338
 
380
339
  if (!response.ok) {
381
- let errorMessage = `Failed to initiate verification: ${response.status} ${response.statusText}`;
382
-
383
- if (response.status === 400) {
384
- try {
385
- const errorData = await response.json();
386
- errorMessage = errorData.message || "Invalid request data";
387
- } catch {
388
- errorMessage = "Invalid request data";
389
- }
390
- } else if (response.status === 401) {
391
- errorMessage = "Unauthorized: Invalid or expired token";
392
- } else if (response.status === 404) {
393
- errorMessage = "Organization or workspace not found";
394
- } else if (response.status === 422) {
395
- try {
396
- const errorData = await response.json();
397
- errorMessage = errorData.message || "Validation error: Please check your input";
398
- } catch {
399
- errorMessage = "Validation error: Please check your input";
400
- }
401
- } else if (response.status >= 500) {
402
- errorMessage = "Server error: Please try again later";
403
- }
404
-
405
- const error = new Error(errorMessage) as Error & { status?: number };
406
- error.status = response.status;
407
- throw error;
340
+ throw await parseApiError(
341
+ response,
342
+ "Could not start guardian verification. Please try again."
343
+ );
408
344
  }
409
345
 
410
346
  const responseData = await response.json();
@@ -486,28 +422,10 @@ export const verifyGuardianStatus = async ({
486
422
  );
487
423
 
488
424
  if (!response.ok) {
489
- let errorMessage = `Failed to check verification status: ${response.status} ${response.statusText}`;
490
-
491
- if (response.status === 400) {
492
- try {
493
- const errorData = await response.json();
494
- errorMessage = errorData.message || errorData.detail?.message || "Invalid session token";
495
- } catch {
496
- errorMessage = "Invalid session token";
497
- }
498
- } else if (response.status === 401) {
499
- errorMessage = "Unauthorized: Invalid or expired token";
500
- } else if (response.status === 404) {
501
- errorMessage = "Verification session not found or expired. Please restart the verification process.";
502
- } else if (response.status === 410) {
503
- errorMessage = "Verification session has expired. Please restart the verification process.";
504
- } else if (response.status >= 500) {
505
- errorMessage = "Server error: Please try again later";
506
- }
507
-
508
- const error = new Error(errorMessage) as Error & { status?: number };
509
- error.status = response.status;
510
- throw error;
425
+ throw await parseApiError(
426
+ response,
427
+ "Could not check verification status. Please try again."
428
+ );
511
429
  }
512
430
 
513
431
  const responseData = await response.json();
@@ -363,11 +363,12 @@ const RedactoConsentInlineComponent = ({
363
363
  // Reset ref on success to allow re-submission if needed
364
364
  hasSubmittedRef.current = false;
365
365
  } catch (error) {
366
- const err = error as Error & { status?: number };
366
+ const err = error as Error;
367
+ // The SDK is a passthrough: server-owned message wins. Only the
368
+ // bare-Error fallback is used when no message is present (e.g. a
369
+ // network failure that never produced a Response).
367
370
  const message =
368
- err.status === 500
369
- ? "An error occurred while submitting your consent. Please try again later."
370
- : err.message || "Failed to submit consent. Please try again.";
371
+ err.message || "Failed to submit consent. Please try again.";
371
372
  setErrorMessage(message);
372
373
  // Reset ref on error to allow retry
373
374
  hasSubmittedRef.current = false;
@@ -1,3 +1,4 @@
1
+ import { parseApiError } from "../../shared/api-errors";
1
2
  import type {
2
3
  ConsentContent,
3
4
  ConsentEventPayload,
@@ -14,51 +15,32 @@ export const fetchConsentContent = async ({
14
15
  baseUrl,
15
16
  specific_uuid,
16
17
  }: FetchConsentContentParams): Promise<ConsentContent> => {
17
- try {
18
- const apiBaseUrl = baseUrl || BASE_URL;
18
+ const apiBaseUrl = baseUrl || BASE_URL;
19
19
 
20
- const url = new URL(
21
- `${apiBaseUrl}/public/organisations/${org_uuid}/workspaces/${workspace_uuid}/notices/get-notice/${notice_uuid}`
22
- );
23
-
24
- if (specific_uuid) {
25
- url.searchParams.append("specific_uuid", specific_uuid);
26
- }
27
-
28
- const response = await fetch(url.toString(), {
29
- method: "GET",
30
- headers: {
31
- "Content-Type": "application/json",
32
- },
33
- });
20
+ const url = new URL(
21
+ `${apiBaseUrl}/public/organisations/${org_uuid}/workspaces/${workspace_uuid}/notices/get-notice/${notice_uuid}`
22
+ );
34
23
 
35
- if (response.status === 401) {
36
- const error = new Error("Unauthorized") as Error & {
37
- status?: number;
38
- };
39
- error.status = 401;
40
- throw error;
41
- }
42
-
43
- if (response.status === 409) {
44
- const error = new Error("User has already provided consent") as Error & {
45
- status?: number;
46
- };
47
- error.status = 409;
48
- throw error;
49
- }
50
-
51
- if (!response.ok) {
52
- throw new Error(
53
- `Failed to fetch consent content: ${response.statusText}`
54
- );
55
- }
24
+ if (specific_uuid) {
25
+ url.searchParams.append("specific_uuid", specific_uuid);
26
+ }
56
27
 
57
- const data = await response.json();
58
- return data;
59
- } catch (error) {
60
- throw error;
28
+ const response = await fetch(url.toString(), {
29
+ method: "GET",
30
+ headers: {
31
+ "Content-Type": "application/json",
32
+ },
33
+ });
34
+
35
+ if (!response.ok) {
36
+ throw await parseApiError(
37
+ response,
38
+ "Could not load the consent notice. Please try again."
39
+ );
61
40
  }
41
+
42
+ const data = await response.json();
43
+ return data;
62
44
  };
63
45
 
64
46
  export const submitConsentEvent = async ({
@@ -71,86 +53,51 @@ export const submitConsentEvent = async ({
71
53
  declined,
72
54
  meta_data,
73
55
  }: SubmitConsentEventParams): Promise<void> => {
74
- try {
75
- const apiBaseUrl = baseUrl || BASE_URL;
76
-
77
- const payload: ConsentEventPayload = {
78
- notice_uuid: noticeUuid,
79
- purposes: purposes.map((purpose) => {
80
- if (!purpose.uuid) {
81
- throw new Error(`Invalid purpose data: missing uuid`);
82
- }
83
-
84
- // Only include enabled data elements (disabled elements are not selectable)
85
- const dataElements = purpose.data_elements
86
- .filter((element) => element.enabled)
87
- .map((element) => ({
88
- uuid: element.uuid,
89
- selected: element.required ? true : Boolean(element.selected),
90
- }));
91
-
92
- return {
93
- purpose_uuid: purpose.uuid,
94
- selected: Boolean(purpose.selected),
95
- data_elements: dataElements.length > 0 ? dataElements : undefined,
96
- };
97
- }),
98
- select_all_mandatory: false,
99
- source: "WEB",
100
- declined,
101
- meta_data,
102
- };
103
-
104
- const response = await fetch(
105
- `${apiBaseUrl}/public/organisations/${org_uuid}/workspaces/${workspace_uuid}/submit-consent`,
106
- {
107
- method: "POST",
108
- headers: {
109
- Authorization: `Bearer ${accessToken}`,
110
- "Content-Type": "application/json",
111
- },
112
- body: JSON.stringify(payload),
113
- }
114
- );
56
+ const apiBaseUrl = baseUrl || BASE_URL;
115
57
 
116
- if (!response.ok) {
117
- // Try to parse error response body
118
- let errorData;
119
- try {
120
- errorData = await response.json();
121
- } catch {
122
- // If parsing fails, use status text
123
- errorData = { message: response.statusText };
58
+ const payload: ConsentEventPayload = {
59
+ notice_uuid: noticeUuid,
60
+ purposes: purposes.map((purpose) => {
61
+ if (!purpose.uuid) {
62
+ throw new Error(`Invalid purpose data: missing uuid`);
124
63
  }
125
64
 
126
- // Handle 409 CONFLICT error (user has already consented)
127
- if (response.status === 409) {
128
- const error = new Error(
129
- errorData.detail?.message || errorData.message || "User has already provided consent"
130
- ) as Error & {
131
- status?: number;
132
- code?: number;
133
- detail?: { message?: string };
134
- };
135
- error.status = 409;
136
- error.code = errorData.code || 409;
137
- error.detail = errorData.detail;
138
- throw error;
139
- }
140
-
141
- const error = new Error(
142
- errorData.detail?.message || errorData.message || `Failed to submit consent event: ${response.statusText}`
143
- ) as Error & {
144
- status?: number;
145
- code?: number;
146
- detail?: { message?: string };
65
+ // Only include enabled data elements (disabled elements are not selectable)
66
+ const dataElements = purpose.data_elements
67
+ .filter((element) => element.enabled)
68
+ .map((element) => ({
69
+ uuid: element.uuid,
70
+ selected: element.required ? true : Boolean(element.selected),
71
+ }));
72
+
73
+ return {
74
+ purpose_uuid: purpose.uuid,
75
+ selected: Boolean(purpose.selected),
76
+ data_elements: dataElements.length > 0 ? dataElements : undefined,
147
77
  };
148
- error.status = response.status;
149
- error.code = errorData.code;
150
- error.detail = errorData.detail;
151
- throw error;
78
+ }),
79
+ select_all_mandatory: false,
80
+ source: "WEB",
81
+ declined,
82
+ meta_data,
83
+ };
84
+
85
+ const response = await fetch(
86
+ `${apiBaseUrl}/public/organisations/${org_uuid}/workspaces/${workspace_uuid}/submit-consent`,
87
+ {
88
+ method: "POST",
89
+ headers: {
90
+ Authorization: `Bearer ${accessToken}`,
91
+ "Content-Type": "application/json",
92
+ },
93
+ body: JSON.stringify(payload),
152
94
  }
153
- } catch (error) {
154
- throw error;
95
+ );
96
+
97
+ if (!response.ok) {
98
+ throw await parseApiError(
99
+ response,
100
+ "Could not submit your consent. Please try again."
101
+ );
155
102
  }
156
103
  };
@@ -0,0 +1,174 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { IoChevronBackOutline } from 'react-icons/io5';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { usePageChrome } from './context/PageChromeContext';
5
+ import { fetchNotifications } from './lib/api';
6
+ import type { Notification, NotificationSummary, NotificationCTA, FilterTab } from './lib/types';
7
+ import { TAB_TO_CATEGORY, POLL_INTERVAL, NOTIFICATION_PAGE_SIZE, useNotificationStatLabels } from './lib/constants';
8
+ import { useCurrentLanguage } from './lib/useCurrentLanguage';
9
+ import { StatCard } from './components/NotificationHub/StatCard';
10
+ import { NotificationListPanel } from './components/NotificationHub/NotificationListPanel';
11
+ import { useNotificationActions } from './components/NotificationHub/useNotificationActions';
12
+
13
+ export const NotificationsPage = () => {
14
+ const { t } = useTranslation();
15
+ const { langParam } = useCurrentLanguage();
16
+ const { setPageChromeOverride, clearPageChromeOverride } = usePageChrome();
17
+ const statLabels = useNotificationStatLabels();
18
+ const {
19
+ organizationId, workspaceId, authContext, consentClient, navigateTo,
20
+ hasAcknowledgeCTA, handleMarkRead, handleMarkAllRead, handleAcknowledge, handleCTAClick,
21
+ } = useNotificationActions();
22
+
23
+ const [notifications, setNotifications] = useState<Notification[]>([]);
24
+ const [summary, setSummary] = useState<NotificationSummary>({ total: 0, unread: 0, action_needed: 0, acknowledged: 0 });
25
+ const [activeTab, setActiveTab] = useState<FilterTab>('all');
26
+ const [isLoading, setIsLoading] = useState(true);
27
+ const [isRefreshing, setIsRefreshing] = useState(false);
28
+ const [totalCount, setTotalCount] = useState(0);
29
+ const [skip, setSkip] = useState(0);
30
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
31
+ const loadNotificationsRef = useRef<(includeSummary?: boolean, silent?: boolean) => Promise<void>>();
32
+
33
+ useEffect(() => {
34
+ setPageChromeOverride({ titleKey: 'notificationHub', descriptionKey: 'notificationHubSubtitle' });
35
+ return () => clearPageChromeOverride();
36
+ }, [setPageChromeOverride, clearPageChromeOverride]);
37
+
38
+ const loadNotifications = useCallback(async (includeSummary = false, silent = false) => {
39
+ if (!organizationId || !workspaceId) {
40
+ return;
41
+ }
42
+ if (!silent) {
43
+ setIsLoading(true);
44
+ }
45
+ try {
46
+ const result = await fetchNotifications(organizationId, workspaceId, {
47
+ category: TAB_TO_CATEGORY[activeTab], skip, limit: NOTIFICATION_PAGE_SIZE,
48
+ include_summary: includeSummary,
49
+ }, authContext, consentClient, langParam);
50
+ if (result) {
51
+ setNotifications(result.data);
52
+ if (result.summary) {
53
+ setSummary(result.summary);
54
+ }
55
+ setTotalCount(result.total);
56
+ }
57
+ } finally {
58
+ setIsLoading(false);
59
+ setIsRefreshing(false);
60
+ }
61
+ }, [organizationId, workspaceId, activeTab, skip, authContext, consentClient, langParam]);
62
+
63
+ // Initial load with summary
64
+ useEffect(() => { loadNotifications(true); }, [loadNotifications]);
65
+
66
+ // Keep ref pointed at the latest loadNotifications so the polling effect
67
+ // can call it without depending on (and resetting on) tab/pagination changes.
68
+ useEffect(() => {
69
+ loadNotificationsRef.current = loadNotifications;
70
+ }, [loadNotifications]);
71
+
72
+ // Silent poll without summary (counts don't change on pagination)
73
+ useEffect(() => {
74
+ pollRef.current = setInterval(() => {
75
+ loadNotificationsRef.current?.(false, true);
76
+ }, POLL_INTERVAL);
77
+ return () => {
78
+ if (pollRef.current) {
79
+ clearInterval(pollRef.current);
80
+ }
81
+ };
82
+ }, []);
83
+
84
+ useEffect(() => { setSkip(0); }, [activeTab]);
85
+
86
+ const onNotificationClick = useCallback(async (notification: Notification) => {
87
+ if (!organizationId || !workspaceId) {
88
+ return;
89
+ }
90
+ const shouldMarkRead = !notification.is_read;
91
+ const shouldAutoAcknowledge = !notification.is_acknowledged && !hasAcknowledgeCTA(notification);
92
+ if (!shouldMarkRead && !shouldAutoAcknowledge) {
93
+ return;
94
+ }
95
+
96
+ const markedRead = shouldMarkRead ? await handleMarkRead(notification.uuid) : false;
97
+ const acknowledged = shouldAutoAcknowledge ? await handleAcknowledge(notification.uuid) : false;
98
+ if (!markedRead && !acknowledged) {
99
+ return;
100
+ }
101
+
102
+ setNotifications((prev) => prev.map((n) =>
103
+ n.uuid === notification.uuid ? { ...n, ...(markedRead ? { is_read: true } : {}), ...(acknowledged ? { is_acknowledged: true } : {}) } : n
104
+ ));
105
+ setSummary((prev) => ({
106
+ ...prev,
107
+ unread: Math.max(0, prev.unread - (markedRead ? 1 : 0)),
108
+ acknowledged: prev.acknowledged + (acknowledged && !notification.is_acknowledged ? 1 : 0),
109
+ }));
110
+ }, [organizationId, workspaceId, hasAcknowledgeCTA, handleMarkRead, handleAcknowledge]);
111
+
112
+ const onCTAClick = useCallback(async (notification: Notification, cta: NotificationCTA) => {
113
+ const result = await handleCTAClick(notification, cta);
114
+ if (result === 'acknowledged') {
115
+ setNotifications((prev) => prev.map((n) =>
116
+ n.uuid === notification.uuid ? { ...n, is_read: true, is_acknowledged: true } : n
117
+ ));
118
+ setSummary((prev) => ({
119
+ ...prev,
120
+ unread: Math.max(0, prev.unread - (notification.is_read ? 0 : 1)),
121
+ acknowledged: prev.acknowledged + (notification.is_acknowledged ? 0 : 1),
122
+ }));
123
+ } else if (result) {
124
+ onNotificationClick(notification);
125
+ }
126
+ }, [handleCTAClick, onNotificationClick]);
127
+
128
+ const onMarkAllRead = useCallback(async () => {
129
+ const ok = await handleMarkAllRead();
130
+ if (!ok) {
131
+ return;
132
+ }
133
+ setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
134
+ setSummary((prev) => ({ ...prev, unread: 0 }));
135
+ }, [handleMarkAllRead]);
136
+
137
+ const onRefresh = useCallback(() => {
138
+ setIsRefreshing(true);
139
+ loadNotifications(true);
140
+ }, [loadNotifications]);
141
+
142
+ const onBack = useCallback(() => navigateTo('consent-manager'), [navigateTo]);
143
+
144
+ return (
145
+ <div className="pc-notification-page">
146
+ <button type="button" onClick={onBack} className="pc-notification-back-btn">
147
+ <IoChevronBackOutline size={14} />
148
+ {t('backToManageConsent')}
149
+ </button>
150
+
151
+ <div className="pc-notification-stats-grid">
152
+ <StatCard value={summary.total} label={statLabels.total} />
153
+ <StatCard value={summary.unread} label={statLabels.unread} />
154
+ <StatCard value={summary.action_needed} label={statLabels.action_needed} />
155
+ <StatCard value={summary.acknowledged} label={statLabels.acknowledged} />
156
+ </div>
157
+
158
+ <NotificationListPanel
159
+ notifications={notifications}
160
+ totalCount={totalCount}
161
+ activeTab={activeTab}
162
+ onTabChange={setActiveTab}
163
+ skip={skip}
164
+ onSkipChange={setSkip}
165
+ isLoading={isLoading}
166
+ isRefreshing={isRefreshing}
167
+ onRefresh={onRefresh}
168
+ onMarkAllRead={onMarkAllRead}
169
+ onNotificationClick={onNotificationClick}
170
+ onCTAClick={onCTAClick}
171
+ />
172
+ </div>
173
+ );
174
+ };
@@ -5,6 +5,7 @@ import { PrivacyCenterConsentManagerPage } from './PrivacyCenterConsentManagerPa
5
5
  import { PrivacyCenterFormPage } from './PrivacyCenterFormPage';
6
6
  import { PrivacyCenterActivityPage } from './PrivacyCenterActivityPage';
7
7
  import { PrivacyCenterCaseDetailsPage } from './PrivacyCenterCaseDetailsPage';
8
+ import { NotificationsPage } from './NotificationsPage';
8
9
 
9
10
  export function PrivacyCenterHomePage() {
10
11
  const { currentPage } = useInternalNavigation();
@@ -24,6 +25,8 @@ export function PrivacyCenterHomePage() {
24
25
  return <PrivacyCenterActivityPage />;
25
26
  case 'case-details':
26
27
  return <PrivacyCenterCaseDetailsPage />;
28
+ case 'notifications':
29
+ return <NotificationsPage />;
27
30
  default:
28
31
  return <PrivacyCenterConsentManagerPage />;
29
32
  }