@redacto.io/consent-sdk-react 5.3.0 → 7.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 (68) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/CHANGELOG.md +23 -0
  3. package/dist/privacy-center.d.mts +100 -42
  4. package/dist/privacy-center.d.ts +100 -42
  5. package/dist/privacy-center.js +4232 -2247
  6. package/dist/privacy-center.mjs +4266 -2284
  7. package/package.json +1 -1
  8. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +6 -10
  9. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +18 -17
  10. package/src/RedactoPrivacyCenter/api/actions.ts +52 -8
  11. package/src/RedactoPrivacyCenter/api/client.ts +58 -15
  12. package/src/RedactoPrivacyCenter/api/orgBranding.ts +9 -0
  13. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +729 -278
  14. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +187 -0
  15. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +65 -165
  16. package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +179 -0
  17. package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +318 -0
  18. package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +333 -0
  19. package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +102 -0
  20. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +6 -2
  21. package/src/RedactoPrivacyCenter/components/Form.tsx +3 -3
  22. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +9 -6
  23. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +1 -1
  24. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +101 -56
  25. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +16 -2
  26. package/src/RedactoPrivacyCenter/components/types.ts +2 -5
  27. package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +44 -0
  28. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +21 -1
  29. package/src/RedactoPrivacyCenter/lib/api.ts +70 -28
  30. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +4 -2
  31. package/src/RedactoPrivacyCenter/lib/types.ts +79 -36
  32. package/src/RedactoPrivacyCenter/locales/as/translation.json +12 -1
  33. package/src/RedactoPrivacyCenter/locales/bho/translation.json +12 -1
  34. package/src/RedactoPrivacyCenter/locales/bn/translation.json +12 -1
  35. package/src/RedactoPrivacyCenter/locales/brx/translation.json +12 -1
  36. package/src/RedactoPrivacyCenter/locales/doi/translation.json +12 -1
  37. package/src/RedactoPrivacyCenter/locales/en/translation.json +16 -1
  38. package/src/RedactoPrivacyCenter/locales/gom/translation.json +12 -1
  39. package/src/RedactoPrivacyCenter/locales/gu/translation.json +12 -1
  40. package/src/RedactoPrivacyCenter/locales/hi/translation.json +15 -1
  41. package/src/RedactoPrivacyCenter/locales/kn/translation.json +12 -1
  42. package/src/RedactoPrivacyCenter/locales/ks/translation.json +12 -1
  43. package/src/RedactoPrivacyCenter/locales/mai/translation.json +12 -1
  44. package/src/RedactoPrivacyCenter/locales/ml/translation.json +12 -1
  45. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +12 -1
  46. package/src/RedactoPrivacyCenter/locales/mr/translation.json +12 -1
  47. package/src/RedactoPrivacyCenter/locales/ne/translation.json +12 -1
  48. package/src/RedactoPrivacyCenter/locales/or/translation.json +12 -1
  49. package/src/RedactoPrivacyCenter/locales/pa/translation.json +12 -1
  50. package/src/RedactoPrivacyCenter/locales/sa/translation.json +12 -1
  51. package/src/RedactoPrivacyCenter/locales/sat/translation.json +12 -1
  52. package/src/RedactoPrivacyCenter/locales/sd/translation.json +12 -1
  53. package/src/RedactoPrivacyCenter/locales/ta/translation.json +12 -1
  54. package/src/RedactoPrivacyCenter/locales/te/translation.json +12 -1
  55. package/src/RedactoPrivacyCenter/locales/ur/translation.json +12 -1
  56. package/src/RedactoPrivacyCenter/styles/applyBrandingTheme.ts +66 -0
  57. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +528 -114
  58. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +3 -2
  59. package/src/RedactoPrivacyCenter/styles/variables.css +5 -42
  60. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +36 -16
  61. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +4 -12
  62. package/src/RedactoPrivacyCenter/ui/Footer.tsx +4 -10
  63. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +1 -1
  64. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +51 -5
  65. package/src/privacy-center.ts +7 -3
  66. package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +0 -85
  67. package/src/RedactoPrivacyCenter/context/theme.tsx +0 -127
  68. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +0 -72
@@ -1,24 +1,47 @@
1
1
  import { useState, useEffect, useCallback, useMemo } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { PCInput, PCSelect, PCButton, PCText, PCModal, type SelectOption } from '../ui';
4
- import { Search, ArrowUpToLine } from 'lucide-react';
4
+ import { Search, ArrowUpToLine, Filter, Circle } from 'lucide-react';
5
5
  import { pcStyles } from '../styles/pcStyles';
6
6
  import { AdvancedTable, AdvancedTablePagination } from '../table';
7
- import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
7
+ import {
8
+ getCoreRowModel,
9
+ getExpandedRowModel,
10
+ useReactTable,
11
+ type ExpandedState,
12
+ type PaginationState,
13
+ type Row,
14
+ type Updater,
15
+ } from '@tanstack/react-table';
8
16
  import {
9
17
  fetchUserConsents,
18
+ fetchGroupPurposes,
10
19
  manageConsent,
11
20
  } from '../lib/api';
12
21
  import { useOrganization } from '../context/OrganizationContext';
13
22
  import { useAuth } from '../context/AuthContext';
14
23
  import { useBaseUrl } from '../context/BaseUrlContext';
15
- import type { UserConsent, UserConsentDetail, ProductConsentHistoryGroup, ConsentAction } from '../lib/types';
24
+ import { usePageChrome } from '../context/PageChromeContext';
25
+ import { ConsentStatusEnum } from '../lib/types';
26
+ import type {
27
+ ConsentAction,
28
+ ConsentGroup,
29
+ ConsentModalRecord,
30
+ DisplayProductGroup,
31
+ DisplayPurposeRow,
32
+ PurposeItem,
33
+ UserConsentDetail,
34
+ } from '../lib/types';
16
35
  import { useCurrentLanguage } from '../lib/useCurrentLanguage';
17
36
  import { exportTableToCSV, useStatusOptions } from '../lib/consent-manager/utils';
18
37
  import { toast } from '../utils/toast';
19
38
  import { ConsentManagerTableSkeleton } from './ConsentManagerTableSkeleton';
20
39
  import { ModifyConsentModal } from './ModifyConsentModal';
40
+ import { useConsentManagerGroupedColumns } from './ConsentManagerGroupedColumns';
21
41
  import { useConsentManagerColumns } from './ConsentManagerColumns';
42
+ import { ConsentManagerExpandedRow } from './ConsentManagerExpandedRow';
43
+ import { ConsentManagerProductCards } from './ConsentManagerProductCards';
44
+ import { ConsentManagerPurposeCards } from './ConsentManagerPurposeCards';
22
45
  import type {
23
46
  ConsentManagerProps,
24
47
  StatusFilterOption,
@@ -30,12 +53,15 @@ import {
30
53
  DEFAULT_PAGE_SIZE,
31
54
  } from './ConsentManagerConstants';
32
55
 
56
+ type ConsentViewMode = 'product' | 'purpose';
57
+
33
58
  export default function ConsentManager({ variant }: ConsentManagerProps) {
34
59
  const { t } = useTranslation();
35
60
  const statusOptions = useStatusOptions();
36
61
  const { organization } = useOrganization();
37
62
  const { accessToken, refreshToken, onError, updateTokens } = useAuth();
38
63
  const { consentClient } = useBaseUrl();
64
+ const { setHeaderStatus } = usePageChrome();
39
65
  const organizationId = organization?.uuid;
40
66
  const workspaceId = organization?.workspaces[0]?.uuid;
41
67
  const contact = sessionStorage.getItem('email') || '';
@@ -53,9 +79,10 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
53
79
 
54
80
  // State management
55
81
  const [activeTab, setActiveTab] = useState<'direct' | 'nominated'>('direct');
82
+ const [viewMode, setViewMode] = useState<ConsentViewMode>('product');
56
83
  const [consentDetail, setConsentDetail] = useState<UserConsentDetail | null>(null);
57
- const [consentData, setConsentData] = useState<UserConsent[]>([]);
58
- const [totalCount, setTotalCount] = useState(0);
84
+ const [consentGroups, setConsentGroups] = useState<DisplayProductGroup[]>([]);
85
+ const [consentRows, setConsentRows] = useState<DisplayPurposeRow[]>([]);
59
86
  const [isLoading, setIsLoading] = useState(true);
60
87
  const [searchQuery, setSearchQuery] = useState('');
61
88
  const [debouncedSearch, setDebouncedSearch] = useState('');
@@ -68,13 +95,45 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
68
95
  []
69
96
  );
70
97
  const [isModalOpen, setIsModalOpen] = useState(false);
71
- const [selectedRecord, setSelectedRecord] = useState<UserConsent | null>(
98
+ const [selectedRecord, setSelectedRecord] = useState<ConsentModalRecord | null>(
72
99
  null
73
100
  );
74
101
  const [exportModal, setExportModal] = useState(false);
75
102
  const [isManageLoading, setIsManageLoading] = useState(false);
76
103
  const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
77
104
  const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
105
+ const [expanded, setExpanded] = useState<ExpandedState>({});
106
+ const [extraPurposesByKey, setExtraPurposesByKey] = useState<Record<string, PurposeItem[]>>({});
107
+ const [hasMoreByKey, setHasMoreByKey] = useState<Record<string, boolean>>({});
108
+ const [loadingMoreByKey, setLoadingMoreByKey] = useState<Record<string, boolean>>({});
109
+ const composedSearchPlaceholder = useMemo(
110
+ () => `${t('search')} ${t('consent')}, ${t('product')}, ${t('purpose')}...`,
111
+ [t]
112
+ );
113
+ const allStatusOption = useMemo<StatusFilterOption>(
114
+ () => ({
115
+ label: `${t('all')} ${t('status')}`,
116
+ value: '__all__',
117
+ }),
118
+ [t]
119
+ );
120
+ const allProductOption = useMemo<ProductFilterOption>(
121
+ () => ({
122
+ label: `${t('all')} ${t('product')}`,
123
+ value: '__all__',
124
+ }),
125
+ [t]
126
+ );
127
+ const statusFilterOptions = useMemo<StatusFilterOption[]>(
128
+ () => [allStatusOption, ...statusOptions],
129
+ [allStatusOption, statusOptions]
130
+ );
131
+ const productFilterOptions = useMemo<ProductFilterOption[]>(
132
+ () => [allProductOption, ...productOptions],
133
+ [allProductOption, productOptions]
134
+ );
135
+ const statusSelectValue = statusFilter ?? allStatusOption;
136
+ const productSelectValue = productFilter ?? allProductOption;
78
137
 
79
138
  // Sync selected status filter label when language changes
80
139
  useEffect(() => {
@@ -96,6 +155,19 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
96
155
  }
97
156
  }, [productOptions, productFilter]);
98
157
 
158
+ // Push user_status into the page header so it renders inline with the page title
159
+ useEffect(() => {
160
+ const status = consentDetail?.user_status;
161
+ if (status === 'TRANSFERRED') {
162
+ setHeaderStatus({ label: t('statusTransferred'), variant: 'warning' });
163
+ } else if (status === 'ACTIVE') {
164
+ setHeaderStatus({ label: t('statusActive'), variant: 'success' });
165
+ } else {
166
+ setHeaderStatus(null);
167
+ }
168
+ return () => setHeaderStatus(null);
169
+ }, [consentDetail?.user_status, setHeaderStatus, t]);
170
+
99
171
  // Debounce search query
100
172
  useEffect(() => {
101
173
  const timer = setTimeout(() => {
@@ -104,25 +176,132 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
104
176
  return () => clearTimeout(timer);
105
177
  }, [searchQuery]);
106
178
 
107
- const handleOpenModal = useCallback((record: UserConsent) => {
179
+ const handleOpenModal = useCallback((record: ConsentModalRecord) => {
108
180
  setSelectedRecord(record);
109
181
  setIsModalOpen(true);
110
182
  }, []);
111
183
 
184
+ const getCardKey = useCallback(
185
+ (group: ConsentGroup): string =>
186
+ group.nominator
187
+ ? `nominated:${group.nominator.uuid}:${group.product_uuid}`
188
+ : `direct:${group.product_uuid}`,
189
+ []
190
+ );
191
+
112
192
  const handleCloseModal = useCallback(() => {
113
193
  setIsModalOpen(false);
114
194
  setSelectedRecord(null);
115
195
  }, []);
116
196
 
197
+ const matchesPurpose = useCallback(
198
+ (
199
+ purpose: PurposeItem,
200
+ group: ConsentGroup,
201
+ normalizedSearch: string,
202
+ statusValue?: string
203
+ ) => {
204
+ const matchesSearch =
205
+ !normalizedSearch ||
206
+ [
207
+ purpose.name,
208
+ purpose.description,
209
+ group.product_name,
210
+ group.product_description,
211
+ purpose.method,
212
+ purpose.status,
213
+ ]
214
+ .filter(Boolean)
215
+ .some((value) => String(value).toLowerCase().includes(normalizedSearch));
216
+
217
+ const matchesStatus =
218
+ !statusValue || purpose.status?.toLowerCase() === statusValue.toLowerCase();
219
+
220
+ return matchesSearch && matchesStatus;
221
+ },
222
+ []
223
+ );
224
+
225
+ const buildDisplayGroups = useCallback(
226
+ (
227
+ groups: ConsentGroup[] | undefined,
228
+ search: string,
229
+ statusValue?: string,
230
+ productUuid?: string
231
+ ): DisplayProductGroup[] => {
232
+ if (!groups || groups.length === 0) return [];
233
+ const normalizedSearch = search.trim().toLowerCase();
234
+
235
+ return groups.reduce<DisplayProductGroup[]>((acc, group) => {
236
+ if (productUuid && group.product_uuid !== productUuid) {
237
+ return acc;
238
+ }
239
+
240
+ const filteredPurposes = (group.purposes || []).filter((purpose) =>
241
+ matchesPurpose(purpose, group, normalizedSearch, statusValue)
242
+ );
243
+
244
+ if (filteredPurposes.length === 0) {
245
+ return acc;
246
+ }
247
+
248
+ const counts = filteredPurposes.reduce(
249
+ (totals, purpose) => {
250
+ const normalized = (typeof purpose.status === 'string' ? purpose.status : '').toUpperCase();
251
+ switch (normalized) {
252
+ case ConsentStatusEnum.ACTIVE:
253
+ totals.active += 1;
254
+ break;
255
+ case ConsentStatusEnum.WITHDRAWN:
256
+ case ConsentStatusEnum.REVOKED:
257
+ totals.withdrawn += 1;
258
+ break;
259
+ case ConsentStatusEnum.EXPIRED:
260
+ totals.expired += 1;
261
+ break;
262
+ case ConsentStatusEnum.DECLINED:
263
+ totals.declined += 1;
264
+ break;
265
+ default:
266
+ break;
267
+ }
268
+ return totals;
269
+ },
270
+ { active: 0, withdrawn: 0, expired: 0, declined: 0 }
271
+ );
272
+
273
+ const latestGivenDate = filteredPurposes.reduce<string | null>((latest, purpose) => {
274
+ if (!purpose.given_date) return latest;
275
+ if (!latest) return purpose.given_date;
276
+ return new Date(purpose.given_date).getTime() > new Date(latest).getTime()
277
+ ? purpose.given_date
278
+ : latest;
279
+ }, null);
280
+
281
+ acc.push({
282
+ ...group,
283
+ purposes: filteredPurposes,
284
+ withdrawn_purposes: counts.withdrawn,
285
+ expired_purposes: counts.expired,
286
+ declined_purposes: counts.declined,
287
+ latest_given_date: latestGivenDate,
288
+ });
289
+ return acc;
290
+ }, []);
291
+ },
292
+ [matchesPurpose]
293
+ );
294
+
117
295
  const flattenProductGroups = useCallback(
118
- (groups: ProductConsentHistoryGroup[] | undefined): UserConsent[] => {
296
+ (groups: ConsentGroup[] | undefined): DisplayPurposeRow[] => {
119
297
  if (!groups || groups.length === 0) return [];
120
298
  return groups.flatMap((group) =>
121
299
  (group.purposes || []).map((purpose) => ({
122
300
  ...purpose,
123
- product_uuid: purpose.product_uuid ?? group.product_uuid,
124
- product_name: purpose.product_name ?? group.product_name,
125
- product_description: purpose.product_description ?? group.product_description ?? null,
301
+ product_uuid: group.product_uuid,
302
+ product_name: group.product_name,
303
+ product_description: group.product_description,
304
+ nominator: group.nominator,
126
305
  }))
127
306
  );
128
307
  },
@@ -131,19 +310,18 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
131
310
 
132
311
  const filterConsentRows = useCallback(
133
312
  (
134
- rows: UserConsent[],
313
+ rows: DisplayPurposeRow[],
135
314
  search: string,
136
315
  statusValue?: string,
137
316
  productUuid?: string
138
- ) => {
317
+ ): DisplayPurposeRow[] => {
139
318
  const normalizedSearch = search.trim().toLowerCase();
140
-
141
319
  return rows.filter((row) => {
142
320
  const matchesSearch =
143
321
  !normalizedSearch ||
144
322
  [
145
- row.purpose,
146
- row.purpose_description,
323
+ row.name,
324
+ row.description,
147
325
  row.product_name,
148
326
  row.product_description,
149
327
  row.method,
@@ -156,9 +334,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
156
334
 
157
335
  const matchesStatus =
158
336
  !statusValue || row.status?.toLowerCase() === statusValue.toLowerCase();
159
-
160
- const matchesProduct =
161
- !productUuid || row.product_uuid === productUuid;
337
+ const matchesProduct = !productUuid || row.product_uuid === productUuid;
162
338
 
163
339
  return matchesSearch && matchesStatus && matchesProduct;
164
340
  });
@@ -167,7 +343,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
167
343
  );
168
344
 
169
345
  const buildProductOptionsFromGroups = useCallback(
170
- (groups: ProductConsentHistoryGroup[] | undefined) => {
346
+ (groups: ConsentGroup[] | undefined) => {
171
347
  if (!groups || groups.length === 0) return [];
172
348
  const productMap = new Map<string, { label: string; value: string }>();
173
349
  groups.forEach((group) => {
@@ -183,31 +359,42 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
183
359
  []
184
360
  );
185
361
 
186
- const getNominatedProductGroups = useCallback(
187
- (detail: UserConsentDetail | null | undefined): ProductConsentHistoryGroup[] => {
188
- if (!detail?.nominated_purposes || detail.nominated_purposes.length === 0) {
189
- return [];
190
- }
191
- return detail.nominated_purposes.flatMap((np) =>
192
- (np.product_groups || []).map((group) => ({
193
- ...group,
194
- purposes: group.purposes.map((purpose) => ({
195
- ...purpose,
196
- nominator_info: np.nominator_info,
197
- })),
198
- }))
199
- );
200
- },
362
+ const groupedColumns = useConsentManagerGroupedColumns();
363
+ const purposeColumns = useConsentManagerColumns({
364
+ onOpenModal: handleOpenModal,
365
+ });
366
+
367
+ const groupedTableClassNames = useMemo(
368
+ () => ({
369
+ container: 'pc-consent-table-container',
370
+ tableClassName: 'pc-consent-table',
371
+ headerClassName: 'pc-consent-table-header',
372
+ bodyClassName: 'pc-consent-table-body',
373
+ rowClassName: 'pc-consent-table-row',
374
+ cellClassName: 'pc-consent-table-cell',
375
+ headerCellClassName: 'pc-consent-table-head-cell',
376
+ expandedRowClassName: 'pc-consent-table-expanded-row',
377
+ expandedCellClassName: 'pc-consent-table-expanded-cell',
378
+ }),
201
379
  []
202
380
  );
203
381
 
204
- const columns = useConsentManagerColumns({
205
- onOpenModal: handleOpenModal,
206
- });
382
+ const purposeTableClassNames = useMemo(
383
+ () => ({
384
+ container: 'pc-consent-table-container',
385
+ tableClassName: 'pc-consent-table pc-consent-table-purpose',
386
+ headerClassName: 'pc-consent-table-header',
387
+ bodyClassName: 'pc-consent-table-body',
388
+ rowClassName: 'pc-consent-table-row',
389
+ cellClassName: 'pc-consent-table-cell',
390
+ headerCellClassName: 'pc-consent-table-head-cell',
391
+ }),
392
+ []
393
+ );
207
394
 
208
395
  // Single data fetch function
209
396
  const fetchData = useCallback(async () => {
210
- if (!organizationId || !workspaceId || !contact) {
397
+ if (!organizationId || !workspaceId) {
211
398
  return;
212
399
  }
213
400
 
@@ -218,31 +405,39 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
218
405
  const statusParam = statusFilter?.value;
219
406
  const productUuidParam = productFilter?.value;
220
407
 
221
- const response = await fetchUserConsents(
408
+ const response = await fetchUserConsents({
222
409
  organizationId,
223
410
  workspaceId,
224
- contact,
225
- offset,
226
- pageSize,
227
- searchParam,
228
- statusParam,
229
- productUuidParam,
411
+ offset: activeTab === 'direct' ? offset : undefined,
412
+ limit: activeTab === 'direct' ? pageSize : undefined,
413
+ nominatedOffset: activeTab === 'nominated' ? offset : undefined,
414
+ nominatedLimit: activeTab === 'nominated' ? pageSize : undefined,
415
+ search: searchParam,
416
+ status: statusParam,
417
+ product_uuid: productUuidParam,
418
+ language: langParam,
230
419
  authContext,
231
420
  consentClient,
232
- langParam
233
- );
421
+ });
234
422
 
235
423
  if (!response) {
236
424
  throw new Error('Failed to fetch consent data');
237
425
  }
238
426
 
239
427
  setConsentDetail(response);
240
-
241
- const directGroups = response?.direct_product_groups;
242
- const nominatedGroups = getNominatedProductGroups(response);
428
+ setExtraPurposesByKey({});
429
+ setHasMoreByKey({});
430
+ setLoadingMoreByKey({});
243
431
 
244
432
  const selectedGroups =
245
- activeTab === 'direct' ? directGroups : nominatedGroups;
433
+ activeTab === 'direct' ? response.direct : response.nominated;
434
+ const displayGroups = buildDisplayGroups(
435
+ selectedGroups,
436
+ debouncedSearch,
437
+ statusParam,
438
+ productUuidParam
439
+ );
440
+ setConsentGroups(displayGroups);
246
441
  const flattenedRows = flattenProductGroups(selectedGroups);
247
442
  const filteredRows = filterConsentRows(
248
443
  flattenedRows,
@@ -250,32 +445,28 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
250
445
  statusParam,
251
446
  productUuidParam
252
447
  );
253
- setConsentData(filteredRows);
254
-
255
- const directCount = response?.status_summary?.direct_purposes;
256
- const nominatedCount = response?.status_summary?.nominated_purposes;
257
- const fallbackTotal = response?.pagination?.total_count ?? 0;
258
- const hasActiveFilters = Boolean(searchParam || statusParam || productUuidParam);
259
- setTotalCount(
260
- hasActiveFilters
261
- ? filteredRows.length
262
- : activeTab === 'direct'
263
- ? (directCount ?? fallbackTotal)
264
- : (nominatedCount ?? fallbackTotal)
265
- );
448
+ setConsentRows(filteredRows);
266
449
 
267
- // Extract unique products from all groups
268
- const combinedGroups = [
269
- ...(response?.direct_product_groups || []),
270
- ...nominatedGroups,
271
- ];
272
- setProductOptions(buildProductOptionsFromGroups(combinedGroups));
450
+ // Only refresh the product filter options from an unfiltered fetch.
451
+ // When a product filter is active the API returns rows for that one
452
+ // product, so re-deriving options here would shrink the dropdown to
453
+ // just the selected product (forcing the user to refresh to recover).
454
+ if (!productFilter) {
455
+ const combinedGroups = [
456
+ ...(response.direct || []),
457
+ ...(response.nominated || []),
458
+ ];
459
+ setProductOptions(buildProductOptionsFromGroups(combinedGroups));
460
+ }
273
461
  } catch (error) {
274
462
  console.error(error);
275
463
  setConsentDetail(null);
276
- setConsentData([]);
277
- setTotalCount(0);
464
+ setConsentGroups([]);
465
+ setConsentRows([]);
278
466
  setProductOptions([]);
467
+ setExtraPurposesByKey({});
468
+ setHasMoreByKey({});
469
+ setLoadingMoreByKey({});
279
470
  toast.error(t('failedToFetchConsentData'));
280
471
  } finally {
281
472
  setIsLoading(false);
@@ -283,7 +474,6 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
283
474
  }, [
284
475
  organizationId,
285
476
  workspaceId,
286
- contact,
287
477
  currentPage,
288
478
  pageSize,
289
479
  debouncedSearch,
@@ -293,10 +483,10 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
293
483
  authContext,
294
484
  consentClient,
295
485
  langParam,
486
+ buildDisplayGroups,
296
487
  flattenProductGroups,
297
488
  filterConsentRows,
298
489
  buildProductOptionsFromGroups,
299
- getNominatedProductGroups,
300
490
  t,
301
491
  ]);
302
492
 
@@ -305,55 +495,234 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
305
495
  fetchData();
306
496
  }, [fetchData]);
307
497
 
308
- const table = useReactTable<UserConsent>({
309
- data: consentData,
310
- columns: columns,
498
+ const hasActiveFilters = Boolean(
499
+ debouncedSearch.trim() || statusFilter?.value || productFilter?.value
500
+ );
501
+
502
+ const groupedPurposeCount = useMemo(
503
+ () => consentGroups.reduce((sum, group) => sum + group.purposes.length, 0),
504
+ [consentGroups]
505
+ );
506
+
507
+ const visibleConsentCount =
508
+ viewMode === 'product' ? groupedPurposeCount : consentRows.length;
509
+
510
+ const visibleProductCount = useMemo(() => {
511
+ if (viewMode === 'product') {
512
+ return consentGroups.length;
513
+ }
514
+ const productIds = new Set(
515
+ consentRows.map((row) => row.product_uuid || row.product_name || '')
516
+ );
517
+ productIds.delete('');
518
+ return productIds.size;
519
+ }, [viewMode, consentGroups, consentRows]);
520
+
521
+ const tableTotalCount = useMemo(() => {
522
+ const pagination =
523
+ activeTab === 'direct'
524
+ ? consentDetail?.direct_pagination
525
+ : consentDetail?.nominated_pagination;
526
+ return pagination?.total_count ?? 0;
527
+ }, [activeTab, consentDetail]);
528
+
529
+ const tablePageCount = Math.ceil(tableTotalCount / pageSize) || 0;
530
+
531
+ const handlePaginationChange = useCallback(
532
+ (updater: Updater<PaginationState>) => {
533
+ const next =
534
+ typeof updater === 'function'
535
+ ? updater({ pageIndex: currentPage - 1, pageSize })
536
+ : updater;
537
+ const nextPage = next.pageIndex + 1;
538
+ const nextSize = next.pageSize;
539
+ if (nextPage === currentPage && nextSize === pageSize) return;
540
+ setCurrentPage(nextPage);
541
+ setPageSize(nextSize);
542
+ },
543
+ [currentPage, pageSize]
544
+ );
545
+
546
+ const productTable = useReactTable<DisplayProductGroup>({
547
+ data: consentGroups,
548
+ columns: groupedColumns,
311
549
  getCoreRowModel: getCoreRowModel(),
550
+ getExpandedRowModel: getExpandedRowModel(),
551
+ getRowCanExpand: () => true,
312
552
  manualPagination: true,
313
- pageCount: Math.ceil(totalCount / pageSize) || 0,
553
+ pageCount: tablePageCount,
314
554
  state: {
315
555
  pagination: {
316
556
  pageIndex: currentPage - 1,
317
557
  pageSize: pageSize,
318
558
  },
559
+ expanded,
560
+ },
561
+ onExpandedChange: (updater) => {
562
+ const next = typeof updater === 'function' ? updater(expanded) : updater;
563
+ if (next === true) {
564
+ setExpanded(next);
565
+ return;
566
+ }
567
+ if (!next) {
568
+ setExpanded({});
569
+ return;
570
+ }
571
+
572
+ const nextMap = next as Record<string, boolean>;
573
+ const nextOpenIds = Object.keys(nextMap).filter((rowId) => nextMap[rowId]);
574
+ if (nextOpenIds.length <= 1) {
575
+ setExpanded(nextOpenIds.length === 1 ? { [nextOpenIds[0]]: true } : {});
576
+ return;
577
+ }
578
+
579
+ const prevMap =
580
+ expanded && expanded !== true ? (expanded as Record<string, boolean>) : {};
581
+ const newlyOpenedId =
582
+ nextOpenIds.find((rowId) => !prevMap[rowId]) ??
583
+ nextOpenIds[nextOpenIds.length - 1];
584
+
585
+ setExpanded(newlyOpenedId ? { [newlyOpenedId]: true } : {});
319
586
  },
320
587
  onPaginationChange: (updater) => {
321
- const next =
322
- typeof updater === 'function'
323
- ? updater({ pageIndex: currentPage - 1, pageSize })
324
- : updater;
325
- const nextPage = (next as any).pageIndex + 1;
326
- const nextSize = (next as any).pageSize;
327
- if (nextPage === currentPage && nextSize === pageSize) return;
328
- setCurrentPage(nextPage);
329
- setPageSize(nextSize);
588
+ handlePaginationChange(updater);
589
+ setExpanded({});
590
+ },
591
+ meta: {
592
+ onRowClick: (_row: DisplayProductGroup, tableRow?: Row<DisplayProductGroup>) => {
593
+ tableRow?.toggleExpanded();
594
+ },
595
+ },
596
+ });
597
+
598
+ const purposeTable = useReactTable<DisplayPurposeRow>({
599
+ data: consentRows,
600
+ columns: purposeColumns,
601
+ getCoreRowModel: getCoreRowModel(),
602
+ manualPagination: true,
603
+ pageCount: tablePageCount,
604
+ state: {
605
+ pagination: {
606
+ pageIndex: currentPage - 1,
607
+ pageSize: pageSize,
608
+ },
330
609
  },
610
+ onPaginationChange: handlePaginationChange,
331
611
  });
332
612
 
333
613
  const handleStatusFilterChange = (data: SelectOption | null) => {
334
- setStatusFilter(data as StatusFilterOption | null);
614
+ if (!data || String(data.value) === allStatusOption.value) {
615
+ setStatusFilter(null);
616
+ } else {
617
+ setStatusFilter(data as StatusFilterOption);
618
+ }
335
619
  setCurrentPage(DEFAULT_PAGE);
620
+ setExpanded({});
336
621
  };
337
622
 
338
623
  const handleProductFilterChange = (data: SelectOption | null) => {
339
- setProductFilter(data as ProductFilterOption | null);
624
+ if (!data || String(data.value) === allProductOption.value) {
625
+ setProductFilter(null);
626
+ } else {
627
+ setProductFilter(data as ProductFilterOption);
628
+ }
340
629
  setCurrentPage(DEFAULT_PAGE);
630
+ setExpanded({});
341
631
  };
342
632
 
343
633
  const handleTabChange = (tab: 'direct' | 'nominated') => {
344
634
  setActiveTab(tab);
345
635
  setCurrentPage(DEFAULT_PAGE);
636
+ setExpanded({});
637
+ };
638
+
639
+ const handleViewModeChange = (mode: ConsentViewMode) => {
640
+ if (mode === viewMode) return;
641
+ setViewMode(mode);
642
+ setCurrentPage(DEFAULT_PAGE);
643
+ setExpanded({});
346
644
  };
347
645
 
348
646
  const handleExport = useCallback(() => {
349
- const value = table.getRowModel().rows.map((r) => {
350
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
351
- const { purpose_uuid, nominator_info, ...rowWithoutInternalFields } = r.original;
352
- return rowWithoutInternalFields;
353
- });
647
+ const value = viewMode === 'product'
648
+ ? productTable.getRowModel().rows.flatMap((row) =>
649
+ row.original.purposes.map((purpose) => {
650
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
651
+ const { purpose_uuid, link_reason, data_elements, ...rest } = purpose;
652
+ return {
653
+ ...rest,
654
+ product_name: row.original.product_name,
655
+ product_description: row.original.product_description,
656
+ };
657
+ })
658
+ )
659
+ : purposeTable.getRowModel().rows.map((row) => {
660
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
661
+ const { purpose_uuid, link_reason, nominator, data_elements, ...rest } = row.original;
662
+ return rest;
663
+ });
354
664
  exportTableToCSV(value);
355
665
  setExportModal(false);
356
- }, [table]);
666
+ }, [viewMode, productTable, purposeTable]);
667
+
668
+ const handleLoadMorePurposes = useCallback(
669
+ async (group: DisplayProductGroup) => {
670
+ if (!organizationId || !workspaceId) return;
671
+ const cardKey = getCardKey(group);
672
+ if (loadingMoreByKey[cardKey]) return;
673
+
674
+ const alreadyLoaded = extraPurposesByKey[cardKey]?.length ?? 0;
675
+ const inlineCount = group.purposes.length;
676
+ const offset = inlineCount + alreadyLoaded;
677
+ const statusParam = statusFilter?.value;
678
+ const searchParam = debouncedSearch.trim() || undefined;
679
+
680
+ setLoadingMoreByKey((prev) => ({ ...prev, [cardKey]: true }));
681
+ try {
682
+ const result = await fetchGroupPurposes({
683
+ organizationId,
684
+ workspaceId,
685
+ productUuid: group.product_uuid,
686
+ offset,
687
+ limit: 50,
688
+ status: statusParam,
689
+ search: searchParam,
690
+ language: langParam,
691
+ nominatorUuid: group.nominator?.uuid,
692
+ authContext,
693
+ consentClient,
694
+ });
695
+ if (!result) {
696
+ throw new Error('Failed to load more purposes');
697
+ }
698
+ setExtraPurposesByKey((prev) => ({
699
+ ...prev,
700
+ [cardKey]: [...(prev[cardKey] ?? []), ...result.purposes],
701
+ }));
702
+ const fetchedTotal = offset + result.purposes.length;
703
+ const hasMore = fetchedTotal < result.pagination.total_count;
704
+ setHasMoreByKey((prev) => ({ ...prev, [cardKey]: hasMore }));
705
+ } catch (error) {
706
+ console.error('Failed to load more purposes:', error);
707
+ toast.error(t('failedToFetchConsentData'));
708
+ } finally {
709
+ setLoadingMoreByKey((prev) => ({ ...prev, [cardKey]: false }));
710
+ }
711
+ },
712
+ [
713
+ organizationId,
714
+ workspaceId,
715
+ authContext,
716
+ consentClient,
717
+ langParam,
718
+ statusFilter?.value,
719
+ debouncedSearch,
720
+ extraPurposesByKey,
721
+ loadingMoreByKey,
722
+ getCardKey,
723
+ t,
724
+ ]
725
+ );
357
726
 
358
727
  const handleManageConsent = useCallback(
359
728
  async (
@@ -419,187 +788,219 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
419
788
 
420
789
  const getVar = (varName: string): string => `var(${varName})`;
421
790
 
422
- const directBadge =
423
- consentDetail?.status_summary?.direct_purposes ??
424
- (activeTab === 'direct' ? totalCount : '');
791
+ const directBadge: number | '' =
792
+ consentDetail?.status_summary?.direct_purposes ?? '';
425
793
 
426
794
  const nominatedBadge: number | '' =
427
- consentDetail?.status_summary?.nominated_purposes ??
428
- (activeTab === 'nominated' ? totalCount : '');
429
-
430
- const renderUserRelationshipStrip = () => {
431
- if (!consentDetail) return null;
432
-
433
- const status = consentDetail.user_status;
434
- let statusLabel: string | null = null;
435
- let statusBgColor: string | null = null;
436
- let statusTextColor: string | null = null;
437
- if (status === 'ACTIVE') {
438
- statusLabel = t('statusActive');
439
- statusBgColor = 'rgba(16, 185, 129, 0.1)';
440
- statusTextColor = getVar('--pc-success');
441
- } else if (status === 'TRANSFERRED') {
442
- statusLabel = t('statusTransferred');
443
- statusBgColor = 'rgba(245, 158, 11, 0.1)';
444
- statusTextColor = getVar('--pc-warning');
445
- }
795
+ consentDetail?.status_summary?.nominated_purposes ?? '';
446
796
 
447
- const nominators = consentDetail.nominators || [];
448
- const nominatorLabels = nominators
449
- .map((n) => n.email?.trim() || n.org_user_id?.trim() || n.uuid?.trim() || '')
450
- .filter(Boolean);
451
-
452
- const hasAnyInfo = Boolean(statusLabel) || nominatorLabels.length > 0;
453
- if (!hasAnyInfo) return null;
454
-
455
- return (
456
- <div style={{
457
- marginBottom: getVar('--pc-spacing-md'),
458
- borderRadius: getVar('--pc-border-radius-lg'),
459
- border: `1px solid ${getVar('--pc-border-color')}`,
460
- backgroundColor: getVar('--pc-bg-secondary'),
461
- padding: getVar('--pc-spacing-md'),
462
- }}>
463
- <div style={{
464
- display: 'flex',
465
- flexDirection: 'column',
466
- gap: getVar('--pc-spacing-sm'),
467
- }} className="pc-flex-responsive">
468
- <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
469
- <PCText size="sm" weight="semibold" style={{ margin: 0, color: getVar('--pc-text-primary') }}>
470
- {t('consentVisibility')}
471
- </PCText>
472
- {nominatorLabels.length > 0 && (
473
- <PCText size="sm" style={{ margin: 0, color: getVar('--pc-text-secondary') }}>
474
- {t('managingConsentsFor')}{' '}
475
- <span style={{ fontWeight: 600, color: getVar('--pc-text-primary') }}>
476
- {nominatorLabels.join(', ')}
477
- </span>
478
- </PCText>
479
- )}
480
- </div>
481
- {statusLabel && (
482
- <div style={{ flexShrink: 0 }}>
483
- <span style={{
484
- display: 'inline-flex',
485
- alignItems: 'center',
486
- borderRadius: '9999px',
487
- padding: '0.25rem 0.75rem',
488
- fontSize: getVar('--pc-font-size-xs'),
489
- fontWeight: 600,
490
- backgroundColor: statusBgColor!,
491
- color: statusTextColor!,
492
- }}>
493
- {statusLabel}
494
- </span>
495
- </div>
496
- )}
497
- </div>
498
- </div>
499
- );
500
- };
797
+ const nominatorLabels = (consentDetail?.nominators || [])
798
+ .map((n) => n.email?.trim() || n.org_user_id?.trim() || n.uuid?.trim() || '')
799
+ .filter(Boolean);
800
+ const summaryText = t('productsAndConsentsSummary', {
801
+ products: visibleProductCount,
802
+ consents: visibleConsentCount,
803
+ });
501
804
 
502
805
  return (
503
- <div style={{ width: '100%' }}>
504
- {/* User Relationship Strip */}
505
- {renderUserRelationshipStrip()}
506
-
806
+ <div className="pc-consent-manager-surface" style={{ width: '100%' }}>
507
807
  <div
808
+ className="pc-consent-shell-card"
508
809
  style={{
509
- borderRadius: getVar('--pc-border-radius-lg'),
510
- border: `1px solid ${getVar('--pc-border-color')}`,
511
- backgroundColor: getVar('--pc-bg-primary'),
512
- boxShadow: getVar('--pc-shadow-sm'),
513
- padding: getVar('--pc-spacing-md'),
514
- marginBottom: getVar('--pc-spacing-lg'),
810
+ backgroundColor: 'transparent',
515
811
  }}
516
812
  >
517
- {/* Tabs: My Consents / Managing */}
518
- <div style={{
519
- display: 'flex',
520
- borderBottom: `1px solid ${getVar('--pc-border-color')}`,
521
- marginBottom: getVar('--pc-spacing-md'),
522
- paddingBottom: '0.125rem',
523
- }}>
524
- <button
525
- type="button"
526
- onClick={() => handleTabChange('direct')}
813
+ {/* Tabs: My Consents / Managing + Product/Purpose toggle */}
814
+ <div
815
+ className="pc-consent-tabs-row"
816
+ style={{
817
+ display: 'flex',
818
+ alignItems: 'center',
819
+ justifyContent: 'space-between',
820
+ gap: getVar('--pc-spacing-sm'),
821
+ borderBottom: `1px solid ${getVar('--pc-border-color')}`,
822
+ padding: '0.125rem 0 0.375rem',
823
+ }}
824
+ >
825
+ <div className="pc-consent-tabs-left" style={{ display: 'flex', minWidth: 0 }}>
826
+ <button
827
+ type="button"
828
+ onClick={() => handleTabChange('direct')}
829
+ style={{
830
+ display: 'flex',
831
+ alignItems: 'center',
832
+ gap: getVar('--pc-spacing-sm'),
833
+ padding: `0.5rem ${getVar('--pc-spacing-sm')}`,
834
+ fontSize: '0.75rem',
835
+ fontWeight: 600,
836
+ background: 'none',
837
+ border: 'none',
838
+ cursor: 'pointer',
839
+ borderBottom: activeTab === 'direct' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
840
+ color: activeTab === 'direct' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
841
+ transition: 'color 0.2s, border-color 0.2s',
842
+ }}
843
+ >
844
+ {t('myConsents')}
845
+ <span style={{
846
+ borderRadius: '9999px',
847
+ backgroundColor: getVar('--pc-bg-tertiary'),
848
+ padding: '0.0625rem 0.375rem',
849
+ fontSize: '0.6875rem',
850
+ color: getVar('--pc-text-secondary'),
851
+ }}>
852
+ {directBadge}
853
+ </span>
854
+ </button>
855
+ <button
856
+ type="button"
857
+ onClick={() => handleTabChange('nominated')}
858
+ style={{
859
+ display: 'flex',
860
+ alignItems: 'center',
861
+ gap: getVar('--pc-spacing-sm'),
862
+ padding: `0.5rem ${getVar('--pc-spacing-sm')}`,
863
+ fontSize: '0.75rem',
864
+ fontWeight: 600,
865
+ background: 'none',
866
+ border: 'none',
867
+ cursor: 'pointer',
868
+ borderBottom: activeTab === 'nominated' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
869
+ color: activeTab === 'nominated' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
870
+ transition: 'color 0.2s, border-color 0.2s',
871
+ }}
872
+ >
873
+ {t('managing')}
874
+ <span style={{
875
+ borderRadius: '9999px',
876
+ backgroundColor: getVar('--pc-bg-tertiary'),
877
+ padding: '0.0625rem 0.375rem',
878
+ fontSize: '0.6875rem',
879
+ color: getVar('--pc-text-secondary'),
880
+ }}>
881
+ {nominatedBadge}
882
+ </span>
883
+ </button>
884
+ </div>
885
+
886
+ <div
887
+ className="pc-consent-view-toggle"
527
888
  style={{
889
+ marginLeft: 'auto',
528
890
  display: 'flex',
529
891
  alignItems: 'center',
530
892
  gap: getVar('--pc-spacing-sm'),
531
- padding: `${getVar('--pc-spacing-sm')} ${getVar('--pc-spacing-md')}`,
532
- fontSize: getVar('--pc-font-size-sm'),
533
- fontWeight: 600,
534
- background: 'none',
535
- border: 'none',
536
- cursor: 'pointer',
537
- borderBottom: activeTab === 'direct' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
538
- color: activeTab === 'direct' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
539
- transition: 'color 0.2s, border-color 0.2s',
893
+ paddingRight: getVar('--pc-spacing-sm'),
540
894
  }}
541
895
  >
542
- {t('myConsents')}
543
- <span style={{
544
- borderRadius: '9999px',
545
- backgroundColor: getVar('--pc-bg-tertiary'),
546
- padding: '0.125rem 0.5rem',
547
- fontSize: getVar('--pc-font-size-xs'),
548
- color: getVar('--pc-text-secondary'),
549
- }}>
550
- {directBadge}
551
- </span>
552
- </button>
553
- <button
554
- type="button"
555
- onClick={() => handleTabChange('nominated')}
896
+ <PCText
897
+ size="sm"
898
+ color="secondary"
899
+ className="pc-consent-view-toggle-label"
900
+ style={{ margin: 0, fontSize: '0.75rem', fontWeight: 500 }}
901
+ >
902
+ {t('consentVisibility')}
903
+ </PCText>
904
+ <div
905
+ className="pc-consent-view-toggle-group"
906
+ style={{
907
+ display: 'inline-flex',
908
+ borderRadius: '9999px',
909
+ backgroundColor: getVar('--pc-bg-secondary'),
910
+ padding: '0.125rem',
911
+ }}
912
+ >
913
+ <button
914
+ type="button"
915
+ className={`pc-consent-view-toggle-btn ${viewMode === 'product' ? 'is-active' : ''}`}
916
+ onClick={() => handleViewModeChange('product')}
917
+ style={{
918
+ minWidth: '5.25rem',
919
+ minHeight: '1.75rem',
920
+ padding: '0.3125rem 0.75rem',
921
+ border: 'none',
922
+ backgroundColor:
923
+ viewMode === 'product' ? getVar('--pc-primary') : 'transparent',
924
+ color:
925
+ viewMode === 'product'
926
+ ? getVar('--pc-text-inverse')
927
+ : getVar('--pc-text-secondary'),
928
+ fontSize: '0.6875rem',
929
+ fontWeight: 600,
930
+ cursor: 'pointer',
931
+ borderRadius: '9999px',
932
+ transition: 'background-color 0.2s ease, color 0.2s ease',
933
+ }}
934
+ >
935
+ {t('product')}
936
+ </button>
937
+ <button
938
+ type="button"
939
+ className={`pc-consent-view-toggle-btn ${viewMode === 'purpose' ? 'is-active' : ''}`}
940
+ onClick={() => handleViewModeChange('purpose')}
941
+ style={{
942
+ minWidth: '5.25rem',
943
+ minHeight: '1.75rem',
944
+ padding: '0.3125rem 0.75rem',
945
+ border: 'none',
946
+ backgroundColor:
947
+ viewMode === 'purpose' ? getVar('--pc-primary') : 'transparent',
948
+ color:
949
+ viewMode === 'purpose'
950
+ ? getVar('--pc-text-inverse')
951
+ : getVar('--pc-text-secondary'),
952
+ fontSize: '0.6875rem',
953
+ fontWeight: 600,
954
+ cursor: 'pointer',
955
+ borderRadius: '9999px',
956
+ transition: 'background-color 0.2s ease, color 0.2s ease',
957
+ }}
958
+ >
959
+ {t('purpose')}
960
+ </button>
961
+ </div>
962
+ </div>
963
+ </div>
964
+
965
+ {activeTab === 'nominated' && nominatorLabels.length > 0 && (
966
+ <PCText
967
+ size="sm"
968
+ color="secondary"
556
969
  style={{
557
- display: 'flex',
558
- alignItems: 'center',
559
- gap: getVar('--pc-spacing-sm'),
560
- padding: `${getVar('--pc-spacing-sm')} ${getVar('--pc-spacing-md')}`,
561
- fontSize: getVar('--pc-font-size-sm'),
562
- fontWeight: 600,
563
- background: 'none',
564
- border: 'none',
565
- cursor: 'pointer',
566
- borderBottom: activeTab === 'nominated' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
567
- color: activeTab === 'nominated' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
568
- transition: 'color 0.2s, border-color 0.2s',
970
+ margin: 0,
971
+ marginBottom: getVar('--pc-spacing-sm'),
972
+ paddingLeft: getVar('--pc-spacing-lg'),
973
+ paddingRight: getVar('--pc-spacing-lg'),
569
974
  }}
570
975
  >
571
- {t('managing')}
572
- <span style={{
573
- borderRadius: '9999px',
574
- backgroundColor: getVar('--pc-bg-tertiary'),
575
- padding: '0.125rem 0.5rem',
576
- fontSize: getVar('--pc-font-size-xs'),
577
- color: getVar('--pc-text-secondary'),
578
- }}>
579
- {nominatedBadge}
976
+ {t('managingConsentsFor')}{' '}
977
+ <span style={{ fontWeight: 600, color: getVar('--pc-text-primary') }}>
978
+ {nominatorLabels.join(', ')}
580
979
  </span>
581
- </button>
582
- </div>
980
+ </PCText>
981
+ )}
583
982
 
584
983
  {/* Filters Section: Search on left, filters and export on the right */}
585
984
  <div style={{
586
985
  display: 'flex',
587
986
  flexDirection: 'column',
588
- gap: getVar('--pc-spacing-md'),
987
+ gap: getVar('--pc-spacing-sm'),
589
988
  width: '100%',
989
+ padding: '0.75rem 0 0.625rem',
590
990
  }} className="pc-filters-responsive">
591
991
  <div style={{ width: '100%' }} className="pc-search-responsive">
592
992
  <PCInput
593
993
  type="search"
994
+ size="sm"
594
995
  clearable
595
996
  clearAriaLabel={t('clearInput')}
596
- placeholder={t('search')}
997
+ placeholder={composedSearchPlaceholder}
597
998
  value={searchQuery}
598
999
  onClear={() => {
599
1000
  setSearchQuery('');
600
1001
  setCurrentPage(DEFAULT_PAGE);
601
1002
  }}
602
- prefix={<Search size={18} strokeWidth={1.8} />}
1003
+ prefix={<Search size={14} strokeWidth={1.8} />}
603
1004
  onChange={(e) => {
604
1005
  setSearchQuery(e.target.value);
605
1006
  setCurrentPage(DEFAULT_PAGE);
@@ -613,57 +1014,124 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
613
1014
  <div style={{
614
1015
  display: 'flex',
615
1016
  flexDirection: 'column',
616
- gap: getVar('--pc-spacing-md'),
1017
+ gap: getVar('--pc-spacing-sm'),
617
1018
  width: '100%',
618
1019
  }} className="pc-filters-row-responsive">
619
1020
  <PCSelect
620
1021
  className="pc-filter-select"
1022
+ size="sm"
621
1023
  placeholder={t('status')}
622
- options={statusOptions}
623
- value={statusFilter}
1024
+ prefix={<Filter size={12} strokeWidth={1.8} />}
1025
+ options={statusFilterOptions}
1026
+ value={statusSelectValue}
624
1027
  onChange={handleStatusFilterChange}
625
- onClear={() => setStatusFilter(null)}
626
- clearable={statusFilter !== null}
627
- clearAriaLabel={t('clearSelection')}
628
- dropdownMatchTriggerWidth
1028
+ showSelectedCheck
629
1029
  style={{
630
1030
  width: '100%',
631
1031
  }}
632
1032
  />
633
1033
  <PCSelect
634
1034
  className="pc-filter-select"
1035
+ size="sm"
635
1036
  placeholder={t('product')}
636
- searchable
637
- searchPlaceholder={t('searchPlaceholder')}
638
- noOptionsMessage={t('noOptionsFound')}
639
- options={productOptions}
640
- value={productFilter}
1037
+ prefix={<Circle size={10} strokeWidth={2} />}
1038
+ options={productFilterOptions}
1039
+ value={productSelectValue}
641
1040
  onChange={handleProductFilterChange}
642
- onClear={() => setProductFilter(null)}
643
- clearable={productFilter !== null}
644
- clearAriaLabel={t('clearSelection')}
645
- dropdownMatchTriggerWidth
1041
+ showSelectedCheck
646
1042
  style={{
647
1043
  width: '100%',
648
1044
  }}
649
1045
  />
1046
+ <PCText
1047
+ size="sm"
1048
+ color="secondary"
1049
+ className="pc-filters-summary"
1050
+ style={{
1051
+ margin: 0,
1052
+ whiteSpace: 'nowrap',
1053
+ alignSelf: 'center',
1054
+ }}
1055
+ >
1056
+ {summaryText}
1057
+ </PCText>
650
1058
  <div className="pc-export-button-responsive">
651
1059
  <PCButton
1060
+ size="sm"
652
1061
  variant="outline"
653
1062
  onClick={() => setExportModal(true)}
654
1063
  style={{
655
1064
  whiteSpace: 'nowrap',
656
1065
  width: '100%',
657
- minHeight: '2.25rem',
658
- padding: '0.4375rem 0.875rem',
659
1066
  }}
660
1067
  >
661
- <ArrowUpToLine size={16} />
1068
+ <ArrowUpToLine size={13} />
662
1069
  <span>{t('export')}</span>
663
1070
  </PCButton>
664
1071
  </div>
665
1072
  </div>
666
1073
  </div>
1074
+ {isLoading ? (
1075
+ <ConsentManagerTableSkeleton />
1076
+ ) : (
1077
+ <>
1078
+ <div className="pc-consent-desktop-view">
1079
+ {viewMode === 'product' ? (
1080
+ <AdvancedTable
1081
+ table={productTable}
1082
+ variant={variant}
1083
+ classNames={groupedTableClassNames}
1084
+ isLoading={isLoading}
1085
+ expandedComponent={(row) => {
1086
+ const cardKey = getCardKey(row.original);
1087
+ return (
1088
+ <ConsentManagerExpandedRow
1089
+ group={row.original}
1090
+ onModify={handleOpenModal}
1091
+ extraPurposes={extraPurposesByKey[cardKey]}
1092
+ hasMore={hasMoreByKey[cardKey] ?? row.original.has_more_purposes}
1093
+ isLoadingMore={loadingMoreByKey[cardKey]}
1094
+ onLoadMore={handleLoadMorePurposes}
1095
+ />
1096
+ );
1097
+ }}
1098
+ />
1099
+ ) : (
1100
+ <AdvancedTable
1101
+ table={purposeTable}
1102
+ variant={variant}
1103
+ classNames={purposeTableClassNames}
1104
+ isLoading={isLoading}
1105
+ />
1106
+ )}
1107
+ </div>
1108
+
1109
+ <div className="pc-consent-mobile-view">
1110
+ {viewMode === 'product' ? (
1111
+ <ConsentManagerProductCards
1112
+ groups={consentGroups}
1113
+ onModify={handleOpenModal}
1114
+ extraPurposesByKey={extraPurposesByKey}
1115
+ hasMoreByKey={hasMoreByKey}
1116
+ loadingMoreByKey={loadingMoreByKey}
1117
+ onLoadMore={handleLoadMorePurposes}
1118
+ cardKeyFor={getCardKey}
1119
+ />
1120
+ ) : (
1121
+ <ConsentManagerPurposeCards
1122
+ rows={consentRows}
1123
+ onModify={handleOpenModal}
1124
+ />
1125
+ )}
1126
+ </div>
1127
+ </>
1128
+ )}
1129
+
1130
+ {viewMode === 'product' && (
1131
+ <div className="pc-consent-pagination-wrap">
1132
+ <AdvancedTablePagination table={productTable} />
1133
+ </div>
1134
+ )}
667
1135
  </div>
668
1136
 
669
1137
  <PCModal
@@ -687,7 +1155,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
687
1155
  }
688
1156
  footer={
689
1157
  <div className="pc-modal-footer-actions">
690
- <PCButton variant="outline" onClick={() => setExportModal(false)}>
1158
+ <PCButton variant="secondary" onClick={() => setExportModal(false)}>
691
1159
  {t('cancel')}
692
1160
  </PCButton>
693
1161
  <PCButton variant="primary" onClick={handleExport}>
@@ -703,23 +1171,6 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
703
1171
  </div>
704
1172
  </PCModal>
705
1173
 
706
- {isLoading ? (
707
- <ConsentManagerTableSkeleton />
708
- ) : (
709
- <AdvancedTable table={table} variant={variant} isLoading={isLoading} />
710
- )}
711
-
712
- {/* Always show pagination, even during loading */}
713
- <div style={{
714
- ...pcStyles.flex,
715
- ...pcStyles.flexCol,
716
- ...pcStyles.itemsCenter,
717
- ...pcStyles.justifyBetween,
718
- ...pcStyles.gapSm,
719
- }} className="pc-flex-responsive">
720
- <AdvancedTablePagination table={table} />
721
- </div>
722
-
723
1174
  <ModifyConsentModal
724
1175
  isOpen={isModalOpen}
725
1176
  onClose={handleCloseModal}