@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
@@ -0,0 +1,318 @@
1
+ import { useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { ChevronDown } from 'lucide-react';
4
+ import { createColumnHelper } from '@tanstack/react-table';
5
+ import dayjs from 'dayjs';
6
+ import relativeTime from 'dayjs/plugin/relativeTime';
7
+ import { PCBadge, PCText } from '../ui';
8
+ import { pcStyles } from '../styles/pcStyles';
9
+ import type { DisplayProductGroup } from '../lib/types';
10
+
11
+ dayjs.extend(relativeTime);
12
+
13
+ const getVar = (varName: string): string => `var(${varName})`;
14
+
15
+ type DotChipVariant = 'success' | 'error' | 'warning';
16
+
17
+ const dotChipColors: Record<DotChipVariant, { bg: string; text: string }> = {
18
+ success: { bg: 'rgba(16, 185, 129, 0.12)', text: getVar('--pc-success') },
19
+ error: { bg: 'rgba(239, 68, 68, 0.12)', text: getVar('--pc-error') },
20
+ warning: { bg: 'rgba(245, 158, 11, 0.12)', text: getVar('--pc-warning') },
21
+ };
22
+
23
+ const ConsentCountChip = ({ count, label }: { count: number; label: string }) => (
24
+ <PCBadge
25
+ className="pc-status-badge"
26
+ size="sm"
27
+ variant="secondary"
28
+ style={{
29
+ display: 'inline-flex',
30
+ alignItems: 'center',
31
+ gap: '0.3125rem',
32
+ padding: '0.1875rem 0.5rem',
33
+ borderRadius: '9999px',
34
+ border: `1px solid ${getVar('--pc-border-color')}`,
35
+ backgroundColor: getVar('--pc-bg-primary'),
36
+ color: getVar('--pc-text-primary'),
37
+ fontSize: '0.6875rem',
38
+ fontWeight: 600,
39
+ lineHeight: 1.2,
40
+ }}
41
+ >
42
+ <span
43
+ aria-hidden="true"
44
+ style={{
45
+ display: 'inline-flex',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ minWidth: '1rem',
49
+ color: getVar('--pc-primary'),
50
+ fontSize: '0.6875rem',
51
+ fontWeight: 700,
52
+ lineHeight: 1,
53
+ }}
54
+ >
55
+ {count}
56
+ </span>
57
+ <span>{label}</span>
58
+ </PCBadge>
59
+ );
60
+
61
+ const DotChip = ({ variant, label }: { variant: DotChipVariant; label: string }) => {
62
+ const colors = dotChipColors[variant];
63
+ return (
64
+ <span
65
+ className="pc-status-badge"
66
+ style={{
67
+ display: 'inline-flex',
68
+ alignItems: 'center',
69
+ gap: '0.3125rem',
70
+ padding: '0.125rem 0.5rem',
71
+ borderRadius: '9999px',
72
+ fontSize: '0.6875rem',
73
+ fontWeight: 600,
74
+ lineHeight: 1.2,
75
+ backgroundColor: colors.bg,
76
+ color: colors.text,
77
+ }}
78
+ >
79
+ <span
80
+ aria-hidden="true"
81
+ style={{
82
+ width: '0.375rem',
83
+ height: '0.375rem',
84
+ borderRadius: '9999px',
85
+ backgroundColor: colors.text,
86
+ flexShrink: 0,
87
+ }}
88
+ />
89
+ {label}
90
+ </span>
91
+ );
92
+ };
93
+
94
+ const avatarColors: Array<'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info'> = [
95
+ 'primary',
96
+ 'info',
97
+ 'success',
98
+ 'warning',
99
+ 'error',
100
+ 'secondary',
101
+ ];
102
+
103
+ const pickAvatarColor = (seed: string) => {
104
+ let hash = 0;
105
+ for (let i = 0; i < seed.length; i += 1) {
106
+ hash = (hash << 5) - hash + seed.charCodeAt(i);
107
+ hash |= 0;
108
+ }
109
+ return avatarColors[Math.abs(hash) % avatarColors.length];
110
+ };
111
+
112
+ const avatarColorMap: Record<ReturnType<typeof pickAvatarColor>, { bg: string; color: string }> = {
113
+ primary: { bg: '#4f46e5', color: '#ffffff' },
114
+ secondary: { bg: '#6b7280', color: '#ffffff' },
115
+ success: { bg: '#10b981', color: '#ffffff' },
116
+ error: { bg: '#ec4899', color: '#ffffff' },
117
+ warning: { bg: '#f59e0b', color: '#ffffff' },
118
+ info: { bg: '#0ea5e9', color: '#ffffff' },
119
+ };
120
+
121
+ const getProductInitials = (name: string) => {
122
+ const words = name.trim().split(/\s+/).filter(Boolean);
123
+ if (words.length >= 2) {
124
+ return `${words[0][0]}${words[1][0]}`.toUpperCase();
125
+ }
126
+ return name.slice(0, 2).toUpperCase();
127
+ };
128
+
129
+ const ProductAvatar = ({ name, seed }: { name: string; seed: string }) => {
130
+ const colors = avatarColorMap[pickAvatarColor(seed)];
131
+ return (
132
+ <span
133
+ style={{
134
+ display: 'inline-flex',
135
+ alignItems: 'center',
136
+ justifyContent: 'center',
137
+ width: '24px',
138
+ height: '24px',
139
+ flexShrink: 0,
140
+ borderRadius: '0.4375rem',
141
+ backgroundColor: colors.bg,
142
+ color: colors.color,
143
+ fontSize: '0.6875rem',
144
+ fontWeight: 700,
145
+ lineHeight: 1,
146
+ }}
147
+ >
148
+ {getProductInitials(name)}
149
+ </span>
150
+ );
151
+ };
152
+
153
+ const columnHelper = createColumnHelper<DisplayProductGroup>();
154
+
155
+ export const useConsentManagerGroupedColumns = () => {
156
+ const { t } = useTranslation();
157
+
158
+ return useMemo(
159
+ () => [
160
+ columnHelper.display({
161
+ id: 'expander',
162
+ size: 36,
163
+ minSize: 36,
164
+ header: '',
165
+ cell: ({ row }) => (
166
+ <button
167
+ type="button"
168
+ aria-label={row.getIsExpanded() ? t('collapseSidebar') : t('expandSidebar')}
169
+ onClick={(e) => {
170
+ e.stopPropagation();
171
+ row.toggleExpanded();
172
+ }}
173
+ style={{
174
+ display: 'flex',
175
+ alignItems: 'center',
176
+ justifyContent: 'center',
177
+ width: '14px',
178
+ height: '14px',
179
+ margin: '0 auto',
180
+ padding: 0,
181
+ border: 'none',
182
+ background: 'transparent',
183
+ cursor: 'pointer',
184
+ color: getVar('--pc-text-secondary'),
185
+ borderRadius: 0,
186
+ transition: 'color 0.15s ease',
187
+ }}
188
+ onMouseEnter={(e) => {
189
+ e.currentTarget.style.color = getVar('--pc-text-primary');
190
+ }}
191
+ onMouseLeave={(e) => {
192
+ e.currentTarget.style.color = getVar('--pc-text-secondary');
193
+ }}
194
+ >
195
+ <ChevronDown
196
+ size={12}
197
+ style={{
198
+ transform: row.getIsExpanded() ? 'rotate(0deg)' : 'rotate(-90deg)',
199
+ transition: 'transform 0.2s ease',
200
+ }}
201
+ />
202
+ </button>
203
+ ),
204
+ }),
205
+
206
+ columnHelper.accessor('product_name', {
207
+ id: 'product',
208
+ size: 280,
209
+ minSize: 200,
210
+ header: t('product'),
211
+ cell: ({ row }) => {
212
+ const group = row.original;
213
+ const name = group.product_name || '-';
214
+ return (
215
+ <div style={{
216
+ ...pcStyles.flex,
217
+ ...pcStyles.itemsCenter,
218
+ gap: getVar('--pc-spacing-sm'),
219
+ minWidth: 0,
220
+ }}>
221
+ <ProductAvatar name={name} seed={group.product_uuid || name} />
222
+ <div style={{ minWidth: 0, ...pcStyles.flex, ...pcStyles.flexCol, gap: '0.125rem' }}>
223
+ <PCText size="sm" weight="semibold">{name}</PCText>
224
+ {group.product_description && (
225
+ <PCText
226
+ size="sm"
227
+ color="secondary"
228
+ style={{
229
+ display: '-webkit-box',
230
+ WebkitLineClamp: 1,
231
+ WebkitBoxOrient: 'vertical',
232
+ overflow: 'hidden',
233
+ wordBreak: 'break-word',
234
+ fontSize: '0.6875rem',
235
+ }}
236
+ title={group.product_description}
237
+ >
238
+ {group.product_description}
239
+ </PCText>
240
+ )}
241
+ </div>
242
+ </div>
243
+ );
244
+ },
245
+ enableSorting: false,
246
+ }),
247
+
248
+ columnHelper.display({
249
+ id: 'consents',
250
+ size: 120,
251
+ minSize: 90,
252
+ header: t('consent'),
253
+ cell: ({ row }) => {
254
+ if (row.getIsExpanded()) return null;
255
+ const count = row.original.total_purposes;
256
+ const consentLabel = t('consent', { count });
257
+ return (
258
+ <div style={{ display: 'inline-flex', alignItems: 'center', minHeight: '1.5rem' }}>
259
+ <ConsentCountChip count={count} label={consentLabel} />
260
+ </div>
261
+ );
262
+ },
263
+ }),
264
+
265
+ columnHelper.display({
266
+ id: 'statusSummary',
267
+ size: 160,
268
+ minSize: 130,
269
+ header: t('status'),
270
+ cell: ({ row }) => {
271
+ if (row.getIsExpanded()) return null;
272
+ const g = row.original;
273
+ const items: Array<{ count: number; label: string; variant: 'success' | 'error' | 'warning' }> = [];
274
+ if (g.active_purposes > 0) {
275
+ items.push({ count: g.active_purposes, label: `${g.active_purposes} ${t('statusActive')}`, variant: 'success' });
276
+ }
277
+ if (g.withdrawn_purposes > 0) {
278
+ items.push({ count: g.withdrawn_purposes, label: `${g.withdrawn_purposes} ${t('statusWithdrawn')}`, variant: 'error' });
279
+ }
280
+ if (g.expired_purposes > 0) {
281
+ items.push({ count: g.expired_purposes, label: `${g.expired_purposes} ${t('statusExpired')}`, variant: 'warning' });
282
+ }
283
+ if (g.declined_purposes > 0) {
284
+ items.push({ count: g.declined_purposes, label: `${g.declined_purposes} ${t('statusDeclined')}`, variant: 'warning' });
285
+ }
286
+ if (items.length === 0) return <PCText size="sm" color="secondary">-</PCText>;
287
+ return (
288
+ <div style={{ ...pcStyles.flex, ...pcStyles.flexCol, gap: '0.25rem', alignItems: 'flex-start' }}>
289
+ {items.map((item) => (
290
+ <DotChip key={item.label} variant={item.variant} label={item.label} />
291
+ ))}
292
+ </div>
293
+ );
294
+ },
295
+ }),
296
+
297
+ columnHelper.display({
298
+ id: 'latestActivity',
299
+ size: 208,
300
+ minSize: 170,
301
+ header: t('activity'),
302
+ cell: ({ row }) => {
303
+ if (row.getIsExpanded()) return null;
304
+ const ts = row.original.latest_given_date;
305
+ if (!ts) return <PCText size="sm" color="secondary">-</PCText>;
306
+ const date = dayjs(ts);
307
+ return (
308
+ <div style={{ ...pcStyles.flex, ...pcStyles.flexCol, gap: '0.0625rem' }}>
309
+ <PCText size="sm" weight="medium">{date.format('MMM D, YYYY • h:mm A')}</PCText>
310
+ <PCText size="sm" color="secondary" style={{ fontSize: '0.6875rem' }}>{date.fromNow()}</PCText>
311
+ </div>
312
+ );
313
+ },
314
+ }),
315
+ ],
316
+ [t]
317
+ );
318
+ };
@@ -0,0 +1,333 @@
1
+ import { type CSSProperties, useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import dayjs from 'dayjs';
4
+ import relativeTime from 'dayjs/plugin/relativeTime';
5
+ import { ChevronDown } from 'lucide-react';
6
+ import { PCBadge, PCButton, PCEmpty, PCText } from '../ui';
7
+ import type {
8
+ ConsentModalRecord,
9
+ DisplayProductGroup,
10
+ PurposeItem,
11
+ } from '../lib/types';
12
+ import { pcStyles } from '../styles/pcStyles';
13
+ import {
14
+ renderGivenDate,
15
+ renderMethod,
16
+ renderModifyButton,
17
+ renderStatusBadge,
18
+ renderValidTill,
19
+ } from './ConsentManagerCells';
20
+
21
+ dayjs.extend(relativeTime);
22
+
23
+ const getVar = (varName: string): string => `var(${varName})`;
24
+
25
+ const avatarColors: Array<'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info'> = [
26
+ 'primary',
27
+ 'info',
28
+ 'success',
29
+ 'warning',
30
+ 'error',
31
+ 'secondary',
32
+ ];
33
+
34
+ const pickAvatarColor = (seed: string) => {
35
+ let hash = 0;
36
+ for (let i = 0; i < seed.length; i += 1) {
37
+ hash = (hash << 5) - hash + seed.charCodeAt(i);
38
+ hash |= 0;
39
+ }
40
+ return avatarColors[Math.abs(hash) % avatarColors.length];
41
+ };
42
+
43
+ const avatarColorMap: Record<ReturnType<typeof pickAvatarColor>, { bg: string; color: string }> = {
44
+ primary: { bg: '#4f46e5', color: '#ffffff' },
45
+ secondary: { bg: '#6b7280', color: '#ffffff' },
46
+ success: { bg: '#10b981', color: '#ffffff' },
47
+ error: { bg: '#ec4899', color: '#ffffff' },
48
+ warning: { bg: '#f59e0b', color: '#ffffff' },
49
+ info: { bg: '#0ea5e9', color: '#ffffff' },
50
+ };
51
+
52
+ const getProductInitials = (name: string) => {
53
+ const words = name.trim().split(/\s+/).filter(Boolean);
54
+ if (words.length >= 2) {
55
+ return `${words[0][0]}${words[1][0]}`.toUpperCase();
56
+ }
57
+ return name.slice(0, 2).toUpperCase();
58
+ };
59
+
60
+ const ProductAvatar = ({ name, seed }: { name: string; seed: string }) => {
61
+ const colors = avatarColorMap[pickAvatarColor(seed)];
62
+ return (
63
+ <span
64
+ className="pc-consent-mobile-avatar"
65
+ style={{
66
+ backgroundColor: colors.bg,
67
+ color: colors.color,
68
+ }}
69
+ >
70
+ {getProductInitials(name)}
71
+ </span>
72
+ );
73
+ };
74
+
75
+ type ProductStatusItem = {
76
+ label: string;
77
+ variant: 'success' | 'error' | 'warning';
78
+ };
79
+
80
+ const statusChipStyles: Record<ProductStatusItem['variant'], CSSProperties> = {
81
+ success: { backgroundColor: 'rgba(16, 185, 129, 0.12)', color: getVar('--pc-success') },
82
+ error: { backgroundColor: 'rgba(239, 68, 68, 0.12)', color: getVar('--pc-error') },
83
+ warning: { backgroundColor: 'rgba(245, 158, 11, 0.12)', color: getVar('--pc-warning') },
84
+ };
85
+
86
+ type ConsentManagerProductCardsProps = {
87
+ groups: DisplayProductGroup[];
88
+ onModify: (record: ConsentModalRecord) => void;
89
+ extraPurposesByKey?: Record<string, PurposeItem[]>;
90
+ hasMoreByKey?: Record<string, boolean>;
91
+ loadingMoreByKey?: Record<string, boolean>;
92
+ onLoadMore?: (group: DisplayProductGroup) => void;
93
+ cardKeyFor?: (group: DisplayProductGroup) => string;
94
+ };
95
+
96
+ const buildModalRecord = (
97
+ purpose: PurposeItem,
98
+ group: DisplayProductGroup
99
+ ): ConsentModalRecord => ({
100
+ ...purpose,
101
+ product_uuid: group.product_uuid,
102
+ product_name: group.product_name,
103
+ product_description: group.product_description,
104
+ nominator: group.nominator,
105
+ });
106
+
107
+ export function ConsentManagerProductCards({
108
+ groups,
109
+ onModify,
110
+ extraPurposesByKey,
111
+ hasMoreByKey,
112
+ loadingMoreByKey,
113
+ onLoadMore,
114
+ cardKeyFor,
115
+ }: ConsentManagerProductCardsProps) {
116
+ const { t } = useTranslation();
117
+ const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({});
118
+
119
+ const handleToggle = (key: string) => {
120
+ setExpandedCards((prev) => ({ ...prev, [key]: !prev[key] }));
121
+ };
122
+
123
+ const getStatusItems = useMemo(
124
+ () =>
125
+ (group: DisplayProductGroup): ProductStatusItem[] => {
126
+ const items: ProductStatusItem[] = [];
127
+ if (group.active_purposes > 0) {
128
+ items.push({
129
+ label: `${group.active_purposes} ${t('statusActive')}`,
130
+ variant: 'success',
131
+ });
132
+ }
133
+ if (group.withdrawn_purposes > 0) {
134
+ items.push({
135
+ label: `${group.withdrawn_purposes} ${t('statusWithdrawn')}`,
136
+ variant: 'error',
137
+ });
138
+ }
139
+ if (group.expired_purposes > 0) {
140
+ items.push({
141
+ label: `${group.expired_purposes} ${t('statusExpired')}`,
142
+ variant: 'warning',
143
+ });
144
+ }
145
+ if (group.declined_purposes > 0) {
146
+ items.push({
147
+ label: `${group.declined_purposes} ${t('statusDeclined')}`,
148
+ variant: 'warning',
149
+ });
150
+ }
151
+ return items;
152
+ },
153
+ [t]
154
+ );
155
+
156
+ if (groups.length === 0) {
157
+ return (
158
+ <div className="pc-consent-mobile-empty">
159
+ <PCEmpty title={t('noData')} />
160
+ </div>
161
+ );
162
+ }
163
+
164
+ return (
165
+ <div className="pc-consent-mobile-list">
166
+ {groups.map((group) => {
167
+ const cardKey = cardKeyFor ? cardKeyFor(group) : (group.product_uuid || group.product_name);
168
+ const productName = group.product_name || '-';
169
+ const statusItems = getStatusItems(group);
170
+ const latestDate = group.latest_given_date ? dayjs(group.latest_given_date) : null;
171
+ const isExpanded = Boolean(expandedCards[cardKey]);
172
+ const extraPurposes = extraPurposesByKey?.[cardKey] ?? [];
173
+ const purposes: PurposeItem[] = [...group.purposes, ...extraPurposes];
174
+ const hasMore = hasMoreByKey?.[cardKey] ?? group.has_more_purposes;
175
+ const isLoadingMore = Boolean(loadingMoreByKey?.[cardKey]);
176
+ const consentLabel = t('consent', { count: group.total_purposes });
177
+
178
+ return (
179
+ <article key={cardKey} className="pc-consent-mobile-card">
180
+ <div className="pc-consent-mobile-card-header">
181
+ <div className="pc-consent-mobile-product-head">
182
+ <ProductAvatar name={productName} seed={group.product_uuid || productName} />
183
+ <div style={{ minWidth: 0 }}>
184
+ <PCText size="sm" weight="semibold">{productName}</PCText>
185
+ {group.product_description && (
186
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-subtle-text">
187
+ {group.product_description}
188
+ </PCText>
189
+ )}
190
+ {group.nominator && (
191
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-subtle-text">
192
+ {t('nominatedBy')}{' '}
193
+ <span style={{ fontWeight: 600 }}>
194
+ {group.nominator.name || group.nominator.email || group.nominator.org_user_id}
195
+ </span>
196
+ </PCText>
197
+ )}
198
+ </div>
199
+ </div>
200
+ <PCBadge
201
+ className="pc-status-badge"
202
+ size="sm"
203
+ variant="secondary"
204
+ style={{
205
+ border: `1px solid ${getVar('--pc-border-color')}`,
206
+ backgroundColor: getVar('--pc-bg-primary'),
207
+ }}
208
+ >
209
+ <span style={{ color: getVar('--pc-primary'), fontWeight: 700 }}>
210
+ {group.total_purposes}
211
+ </span>{' '}
212
+ {consentLabel}
213
+ </PCBadge>
214
+ </div>
215
+
216
+ {statusItems.length > 0 && (
217
+ <div className="pc-consent-mobile-chip-row">
218
+ {statusItems.map((item) => (
219
+ <span
220
+ key={item.label}
221
+ className="pc-status-badge pc-consent-mobile-status-chip"
222
+ style={statusChipStyles[item.variant]}
223
+ >
224
+ {item.label}
225
+ </span>
226
+ ))}
227
+ </div>
228
+ )}
229
+
230
+ <div className="pc-consent-mobile-activity-row">
231
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
232
+ {t('activity')}
233
+ </PCText>
234
+ {latestDate ? (
235
+ <div style={{ ...pcStyles.flex, ...pcStyles.flexCol, gap: '0.0625rem', minWidth: 0 }}>
236
+ <PCText size="sm" weight="medium">{latestDate.format('MMM D, YYYY • h:mm A')}</PCText>
237
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-subtle-text">
238
+ {latestDate.fromNow()}
239
+ </PCText>
240
+ </div>
241
+ ) : (
242
+ <PCText size="sm" color="secondary">-</PCText>
243
+ )}
244
+ </div>
245
+
246
+ <button
247
+ type="button"
248
+ className="pc-consent-mobile-expand-btn"
249
+ onClick={() => handleToggle(cardKey)}
250
+ aria-expanded={isExpanded}
251
+ >
252
+ <span>
253
+ {purposes.length} {t('purpose')}
254
+ </span>
255
+ <ChevronDown
256
+ size={14}
257
+ style={{
258
+ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
259
+ transition: 'transform 0.2s ease',
260
+ }}
261
+ />
262
+ </button>
263
+
264
+ {isExpanded && (
265
+ <div className="pc-consent-mobile-purpose-list">
266
+ {purposes.map((purpose) => {
267
+ const modalRecord = buildModalRecord(purpose, group);
268
+ return (
269
+ <div
270
+ key={purpose.purpose_uuid}
271
+ className="pc-consent-mobile-purpose-item"
272
+ >
273
+ <div className="pc-consent-mobile-purpose-head">
274
+ <div style={{ minWidth: 0 }}>
275
+ <PCText size="sm" weight="semibold">{purpose.name}</PCText>
276
+ {purpose.description && (
277
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-subtle-text">
278
+ {purpose.description}
279
+ </PCText>
280
+ )}
281
+ </div>
282
+ {renderStatusBadge(purpose.status, t)}
283
+ </div>
284
+
285
+ <div className="pc-consent-mobile-meta-grid">
286
+ <div>
287
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
288
+ {t('givenDate')}
289
+ </PCText>
290
+ {renderGivenDate(purpose.given_date, { showTooltip: false })}
291
+ </div>
292
+ <div>
293
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
294
+ {t('validTill')}
295
+ </PCText>
296
+ {renderValidTill(purpose, t)}
297
+ </div>
298
+ <div>
299
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
300
+ {t('method')}
301
+ </PCText>
302
+ {renderMethod(purpose.method)}
303
+ </div>
304
+ <div className="pc-consent-mobile-action-cell">
305
+ <PCText size="sm" color="secondary" className="pc-consent-mobile-meta-label">
306
+ {t('action')}
307
+ </PCText>
308
+ {renderModifyButton(modalRecord, onModify, t)}
309
+ </div>
310
+ </div>
311
+ </div>
312
+ );
313
+ })}
314
+ {hasMore && onLoadMore && (
315
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '0.5rem' }}>
316
+ <PCButton
317
+ size="sm"
318
+ variant="outline"
319
+ onClick={() => onLoadMore(group)}
320
+ disabled={isLoadingMore}
321
+ >
322
+ {isLoadingMore ? t('loading') : t('loadMorePurposes')}
323
+ </PCButton>
324
+ </div>
325
+ )}
326
+ </div>
327
+ )}
328
+ </article>
329
+ );
330
+ })}
331
+ </div>
332
+ );
333
+ }