@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,187 @@
1
+ import type { TFunction } from 'i18next';
2
+ import dayjs from 'dayjs';
3
+ import relativeTime from 'dayjs/plugin/relativeTime';
4
+ import { Smartphone, LaptopMinimal } from 'lucide-react';
5
+ import { PCText, PCTooltip } from '../ui';
6
+ import { pcStyles } from '../styles/pcStyles';
7
+ import { ConsentStatusEnum } from '../lib/types';
8
+ import type { PurposeItem, ConsentModalRecord } from '../lib/types';
9
+ import { formatDateTime } from '../lib/consent-manager/utils';
10
+
11
+ dayjs.extend(relativeTime);
12
+ const getVar = (varName: string): string => `var(${varName})`;
13
+
14
+ type StatusVariant = 'success' | 'error' | 'warning' | 'info' | 'secondary';
15
+
16
+ const statusVariantMap: Record<string, StatusVariant> = {
17
+ [ConsentStatusEnum.ACTIVE]: 'success',
18
+ [ConsentStatusEnum.WITHDRAWN]: 'error',
19
+ [ConsentStatusEnum.REVOKED]: 'error',
20
+ [ConsentStatusEnum.EXPIRED]: 'warning',
21
+ [ConsentStatusEnum.DECLINED]: 'warning',
22
+ };
23
+
24
+ export const getStatusVariant = (status: string): StatusVariant =>
25
+ statusVariantMap[status?.toUpperCase()] ?? 'secondary';
26
+
27
+ export const getStatusLabel = (status: string, t: TFunction): string => {
28
+ const normalized = status?.toUpperCase();
29
+ if (normalized === ConsentStatusEnum.ACTIVE) return t('statusActive');
30
+ if (normalized === ConsentStatusEnum.WITHDRAWN) return t('statusWithdrawn');
31
+ if (normalized === ConsentStatusEnum.REVOKED) return t('statusRevoked');
32
+ if (normalized === ConsentStatusEnum.EXPIRED) return t('statusExpired');
33
+ if (normalized === ConsentStatusEnum.DECLINED) return t('statusDeclined');
34
+ return status;
35
+ };
36
+
37
+ const statusBadgeColors: Record<StatusVariant, { bg: string; fg: string }> = {
38
+ success: { bg: 'rgba(16, 185, 129, 0.12)', fg: 'var(--pc-success)' },
39
+ error: { bg: 'rgba(239, 68, 68, 0.12)', fg: 'var(--pc-error)' },
40
+ warning: { bg: 'rgba(245, 158, 11, 0.12)', fg: 'var(--pc-warning)' },
41
+ info: { bg: 'rgba(59, 130, 246, 0.12)', fg: 'var(--pc-info)' },
42
+ secondary: { bg: 'rgba(107, 114, 128, 0.12)', fg: 'var(--pc-text-secondary)' },
43
+ };
44
+
45
+ export const renderStatusBadge = (status: string, t: TFunction) => {
46
+ const variant = getStatusVariant(status);
47
+ const colors = statusBadgeColors[variant];
48
+ return (
49
+ <div style={{ ...pcStyles.flex, ...pcStyles.itemsCenter, ...pcStyles.justifyStart }}>
50
+ <span
51
+ className="pc-status-badge"
52
+ style={{
53
+ display: 'inline-flex',
54
+ alignItems: 'center',
55
+ gap: '0.3125rem',
56
+ borderRadius: '9999px',
57
+ padding: '0.1875rem 0.5625rem',
58
+ fontSize: '0.6875rem',
59
+ fontWeight: 600,
60
+ backgroundColor: colors.bg,
61
+ color: colors.fg,
62
+ }}
63
+ >
64
+ <span
65
+ aria-hidden="true"
66
+ style={{
67
+ width: '0.375rem',
68
+ height: '0.375rem',
69
+ borderRadius: '9999px',
70
+ backgroundColor: colors.fg,
71
+ flexShrink: 0,
72
+ }}
73
+ />
74
+ {getStatusLabel(status, t)}
75
+ </span>
76
+ </div>
77
+ );
78
+ };
79
+
80
+ export const renderGivenDate = (
81
+ timestamp: string | null,
82
+ options?: { showTooltip?: boolean }
83
+ ) => {
84
+ if (!timestamp) {
85
+ return <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>-</PCText>;
86
+ }
87
+ const date = dayjs(timestamp);
88
+ const formatted = date.format('MMM D, YYYY [at] h:mm A');
89
+ const relative = date.fromNow();
90
+ const showTooltip = options?.showTooltip ?? true;
91
+
92
+ const content = (
93
+ <div style={{
94
+ ...pcStyles.flex,
95
+ ...pcStyles.flexCol,
96
+ gap: '0.125rem',
97
+ minWidth: 0,
98
+ }}>
99
+ <PCText size="sm" weight="medium" color="primary" style={{ lineHeight: 1.25, whiteSpace: 'normal', overflowWrap: 'anywhere' }}>
100
+ {formatted}
101
+ </PCText>
102
+ <PCText size="sm" color="secondary" style={{ fontSize: '0.65625rem', lineHeight: 1.2, whiteSpace: 'normal', overflowWrap: 'anywhere' }}>
103
+ {relative}
104
+ </PCText>
105
+ </div>
106
+ );
107
+
108
+ if (!showTooltip) {
109
+ return content;
110
+ }
111
+
112
+ return (
113
+ <PCTooltip content={date.format('MMMM D, YYYY [at] h:mm:ss A')} placement="top">
114
+ {content}
115
+ </PCTooltip>
116
+ );
117
+ };
118
+
119
+ export const renderValidTill = (consent: PurposeItem, t: TFunction) => {
120
+ const { status, valid_till } = consent;
121
+ const normalized = (typeof status === 'string' ? status : '').toUpperCase();
122
+ const inactiveLike =
123
+ normalized === ConsentStatusEnum.DECLINED ||
124
+ normalized === ConsentStatusEnum.WITHDRAWN ||
125
+ normalized === ConsentStatusEnum.REVOKED;
126
+ if (inactiveLike) {
127
+ return <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>-</PCText>;
128
+ }
129
+ if (normalized === ConsentStatusEnum.ACTIVE && (valid_till === null || valid_till === undefined)) {
130
+ return <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{t('untilWithdrawalNoExpiry')}</PCText>;
131
+ }
132
+ return <PCText size="sm" style={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{valid_till ? formatDateTime(valid_till) : '-'}</PCText>;
133
+ };
134
+
135
+ const methodIconMap: Record<string, JSX.Element> = {
136
+ MOBILE: <Smartphone size={12} style={{ color: 'var(--pc-text-secondary)' }} />,
137
+ WEB: <LaptopMinimal size={12} style={{ color: 'var(--pc-text-secondary)' }} />,
138
+ };
139
+
140
+ export const renderMethod = (method: string) => (
141
+ <div
142
+ style={{
143
+ display: 'inline-flex',
144
+ alignItems: 'center',
145
+ gap: '0.25rem',
146
+ minWidth: '48px',
147
+ borderRadius: getVar('--pc-border-radius-sm'),
148
+ border: `1px solid ${getVar('--pc-border-color')}`,
149
+ backgroundColor: getVar('--pc-bg-primary'),
150
+ padding: '0.125rem 0.375rem',
151
+ }}
152
+ >
153
+ {methodIconMap[method] ?? null}
154
+ <PCText size="sm" color="secondary" style={{ fontSize: '0.6875rem', fontWeight: 600 }}>
155
+ {method}
156
+ </PCText>
157
+ </div>
158
+ );
159
+
160
+ export const renderModifyButton = (
161
+ consent: ConsentModalRecord,
162
+ onModify: (consent: ConsentModalRecord) => void,
163
+ t: TFunction
164
+ ) => {
165
+ const isDisabled = consent.status === ConsentStatusEnum.DECLINED;
166
+ return (
167
+ <button
168
+ type="button"
169
+ style={{
170
+ fontSize: pcStyles.textSm.fontSize,
171
+ fontWeight: pcStyles.fontWeightSemibold.fontWeight,
172
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
173
+ color: isDisabled ? '#9ca3af' : getVar('--pc-primary'),
174
+ background: 'none',
175
+ border: 'none',
176
+ padding: 0,
177
+ }}
178
+ onClick={(e) => {
179
+ e.stopPropagation();
180
+ if (!isDisabled) onModify(consent);
181
+ }}
182
+ disabled={isDisabled}
183
+ >
184
+ {t('modifyButton')}
185
+ </button>
186
+ );
187
+ };
@@ -1,78 +1,57 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { PCText, PCTooltip, PCBadge } from '../ui';
4
- import { pcStyles } from '../styles/pcStyles';
5
- import { Smartphone, LaptopMinimal } from 'lucide-react';
6
3
  import { createColumnHelper } from '@tanstack/react-table';
7
- import dayjs from 'dayjs';
8
- import relativeTime from 'dayjs/plugin/relativeTime';
9
- import { ConsentStatusEnum } from '../lib/types';
10
- import type { UserConsent } from '../lib/types';
11
- import { formatDateTime } from '../lib/consent-manager/utils';
12
- import type { UseConsentManagerColumnsProps } from './types';
4
+ import type { DisplayPurposeRow, ConsentModalRecord } from '../lib/types';
5
+ import { PCText } from '../ui';
6
+ import { pcStyles } from '../styles/pcStyles';
7
+ import {
8
+ renderGivenDate,
9
+ renderMethod,
10
+ renderModifyButton,
11
+ renderStatusBadge,
12
+ renderValidTill,
13
+ } from './ConsentManagerCells';
13
14
 
14
- dayjs.extend(relativeTime);
15
+ type UseConsentManagerColumnsProps = {
16
+ onOpenModal: (record: ConsentModalRecord) => void;
17
+ };
15
18
 
16
- const columnHelper = createColumnHelper<UserConsent>();
19
+ const columnHelper = createColumnHelper<DisplayPurposeRow>();
17
20
 
18
21
  export const useConsentManagerColumns = ({
19
22
  onOpenModal,
20
23
  }: UseConsentManagerColumnsProps) => {
21
24
  const { t } = useTranslation();
25
+
22
26
  return useMemo(
23
27
  () => [
24
- columnHelper.accessor('purpose', {
28
+ columnHelper.accessor('name', {
25
29
  id: 'purpose',
26
- size: 300,
30
+ size: 280,
27
31
  minSize: 200,
28
32
  header: t('purpose'),
29
33
  cell: (info) => (
30
- <div style={{ width: '100%', minWidth: '150px' }}>
31
- <PCText size="sm" weight="medium">{info.getValue()}</PCText>
32
- <PCText
33
- size="sm"
34
- color="secondary"
35
- style={{
36
- display: '-webkit-box',
37
- WebkitLineClamp: 2,
38
- WebkitBoxOrient: 'vertical',
39
- overflow: 'hidden',
40
- wordBreak: 'break-word',
41
- fontSize: '0.75rem',
42
- maxWidth: '400px',
43
- }}
44
- title={info.row.original.purpose_description}
45
- >
46
- {info.row.original.purpose_description}
34
+ <div style={{ width: '100%', minWidth: '10rem' }}>
35
+ <PCText size="sm" weight="semibold">
36
+ {info.getValue()}
47
37
  </PCText>
48
- </div>
49
- ),
50
- enableSorting: false,
51
- enableColumnFilter: false,
52
- }),
53
- columnHelper.accessor('product_name', {
54
- id: 'product',
55
- size: 150,
56
- minSize: 100,
57
- header: t('product'),
58
- cell: (info) => (
59
- <div style={{ width: '100%', minWidth: '80px' }}>
60
- <PCText size="sm" weight="medium">{info.getValue() || '-'}</PCText>
61
- {info.row.original.product_description && (
38
+ {info.row.original.description && (
62
39
  <PCText
63
40
  size="sm"
64
41
  color="secondary"
65
42
  style={{
66
43
  display: '-webkit-box',
67
- WebkitLineClamp: 2,
44
+ WebkitLineClamp: 1,
68
45
  WebkitBoxOrient: 'vertical',
69
46
  overflow: 'hidden',
70
47
  wordBreak: 'break-word',
71
- fontSize: '0.75rem',
48
+ fontSize: '0.6875rem',
49
+ lineHeight: 1.2,
50
+ marginTop: '0.125rem',
72
51
  }}
73
- title={info.row.original.product_description}
52
+ title={info.row.original.description ?? undefined}
74
53
  >
75
- {info.row.original.product_description}
54
+ {info.row.original.description}
76
55
  </PCText>
77
56
  )}
78
57
  </div>
@@ -80,157 +59,78 @@ export const useConsentManagerColumns = ({
80
59
  enableSorting: false,
81
60
  enableColumnFilter: false,
82
61
  }),
62
+
63
+ columnHelper.accessor('product_name', {
64
+ id: 'product',
65
+ size: 180,
66
+ minSize: 130,
67
+ header: t('product'),
68
+ cell: (info) => (
69
+ <div style={{ width: '100%', minWidth: '8rem' }}>
70
+ <PCText size="sm" weight="medium">
71
+ {info.getValue() || '-'}
72
+ </PCText>
73
+ </div>
74
+ ),
75
+ enableSorting: false,
76
+ enableColumnFilter: false,
77
+ }),
78
+
83
79
  columnHelper.accessor('status', {
84
80
  id: 'status',
85
- size: 120,
86
- minSize: 90,
81
+ size: 130,
82
+ minSize: 100,
87
83
  header: t('status'),
88
- cell: (info) => {
89
- const status = info.getValue();
90
- const getStatusName = (status: string) => {
91
- if (status === ConsentStatusEnum.ACTIVE) {
92
- return t('statusActive');
93
- } else if (status === ConsentStatusEnum.WITHDRAWN) {
94
- return t('statusWithdrawn');
95
- } else if (status === ConsentStatusEnum.EXPIRED) {
96
- return t('statusExpired');
97
- } else if (status === ConsentStatusEnum.DECLINED) {
98
- return t('statusDeclined');
99
- } else {
100
- return status;
101
- }
102
- };
103
-
104
- const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'secondary' => {
105
- if (status === ConsentStatusEnum.ACTIVE) return 'success';
106
- if (status === ConsentStatusEnum.WITHDRAWN) return 'error';
107
- if (status === ConsentStatusEnum.EXPIRED) return 'warning';
108
- if (status === ConsentStatusEnum.DECLINED) return 'warning';
109
- return 'secondary';
110
- };
111
-
112
- return (
113
- <div style={{ ...pcStyles.flex, ...pcStyles.itemsCenter, ...pcStyles.justifyStart, width: '100%' }}>
114
- <PCBadge variant={getStatusVariant(status)} size="sm" className="pc-status-badge">
115
- {getStatusName(status)}
116
- </PCBadge>
117
- </div>
118
- );
119
- },
84
+ cell: (info) => renderStatusBadge(info.getValue(), t),
120
85
  enableSorting: false,
121
86
  enableColumnFilter: false,
122
87
  }),
88
+
123
89
  columnHelper.accessor('given_date', {
124
90
  id: 'given_date',
125
91
  size: 180,
126
92
  minSize: 140,
127
93
  header: t('givenDate'),
128
- cell: (info) => {
129
- const timestamp = info.getValue();
130
- const date = dayjs(timestamp);
131
- const formattedDateTime = date.format('MMM D, YYYY [at] h:mm A');
132
- const relativeTimeText = date.fromNow();
133
-
134
- return (
135
- <PCTooltip
136
- content={date.format('MMMM D, YYYY [at] h:mm:ss A')}
137
- placement="top"
138
- >
139
- <div style={{
140
- ...pcStyles.flex,
141
- ...pcStyles.flexCol,
142
- minWidth: '120px',
143
- gap: '0.125rem',
144
- }}>
145
- <PCText size="sm" weight="medium" color="primary">
146
- {formattedDateTime}
147
- </PCText>
148
- <PCText size="sm" color="secondary" style={{ fontSize: '0.75rem' }}>
149
- {relativeTimeText}
150
- </PCText>
151
- </div>
152
- </PCTooltip>
153
- );
154
- },
94
+ cell: (info) => renderGivenDate(info.getValue()),
155
95
  enableSorting: false,
156
96
  enableColumnFilter: false,
157
97
  }),
98
+
158
99
  columnHelper.accessor('valid_till', {
159
100
  id: 'valid_till',
160
- minSize: 100,
101
+ size: 160,
102
+ minSize: 120,
161
103
  header: t('validTill'),
162
- cell: (info) => {
163
- const status = info.row.original.status;
164
- if (
165
- status === ConsentStatusEnum.DECLINED ||
166
- status === ConsentStatusEnum.WITHDRAWN
167
- ) {
168
- return '-';
169
- }
170
- const validTillValue = info.getValue();
171
- if (status === ConsentStatusEnum.ACTIVE && (validTillValue === null || validTillValue === undefined)) {
172
- return <PCText size="sm">{t('untilWithdrawalNoExpiry')}</PCText>;
173
- }
174
- return <PCText size="sm">{formatDateTime(info.getValue())}</PCText>;
175
- },
104
+ cell: (info) => renderValidTill(info.row.original, t),
176
105
  enableSorting: false,
177
106
  enableColumnFilter: false,
178
107
  }),
108
+
179
109
  columnHelper.accessor('method', {
180
110
  id: 'method',
181
111
  size: 120,
182
- minSize: 80,
112
+ minSize: 90,
183
113
  header: t('method'),
184
- cell: (info) => {
185
- const method = info.getValue();
186
- const iconMap: Record<string, JSX.Element> = {
187
- MOBILE: <Smartphone size={12} style={{ marginRight: '0.25rem', color: '#6b7280' }} />,
188
- WEB: <LaptopMinimal size={12} style={{ marginRight: '0.25rem', color: '#6b7280' }} />,
189
- };
190
- return (
191
- <div style={{
192
- ...pcStyles.flex,
193
- ...pcStyles.itemsCenter,
194
- minWidth: '60px',
195
- }}>
196
- {iconMap[method] || null}
197
- <PCText size="sm">{method}</PCText>
198
- </div>
199
- );
200
- },
114
+ cell: (info) => renderMethod(info.getValue()),
201
115
  enableSorting: false,
202
116
  enableColumnFilter: false,
203
117
  }),
204
118
 
205
119
  columnHelper.display({
206
120
  id: 'action',
207
- size: 100,
121
+ size: 90,
208
122
  minSize: 70,
209
123
  header: t('action'),
124
+ meta: {
125
+ align: 'right',
126
+ },
210
127
  enableSorting: false,
211
128
  enableColumnFilter: false,
212
- cell: ({ row }) => {
213
- const isDisabled =
214
- row.original.status === ConsentStatusEnum.DECLINED;
215
-
216
- return (
217
- <button
218
- style={{
219
- fontSize: pcStyles.textSm.fontSize,
220
- fontWeight: pcStyles.fontWeightMedium.fontWeight,
221
- cursor: isDisabled ? 'not-allowed' : 'pointer',
222
- color: isDisabled ? '#9ca3af' : '#4F87FF',
223
- background: 'none',
224
- border: 'none',
225
- padding: 0,
226
- }}
227
- onClick={() => onOpenModal(row.original)}
228
- disabled={isDisabled}
229
- >
230
- {t('modifyButton')}
231
- </button>
232
- );
233
- },
129
+ cell: ({ row }) => (
130
+ <div style={{ ...pcStyles.flex, ...pcStyles.justifyEnd }}>
131
+ {renderModifyButton(row.original, onOpenModal, t)}
132
+ </div>
133
+ ),
234
134
  }),
235
135
  ],
236
136
  [onOpenModal, t]
@@ -0,0 +1,179 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { PCButton, PCText } from '../ui';
3
+ import { pcStyles } from '../styles/pcStyles';
4
+ import type {
5
+ ConsentModalRecord,
6
+ DisplayProductGroup,
7
+ PurposeItem,
8
+ } from '../lib/types';
9
+ import {
10
+ renderStatusBadge,
11
+ renderGivenDate,
12
+ renderValidTill,
13
+ renderMethod,
14
+ renderModifyButton,
15
+ } from './ConsentManagerCells';
16
+
17
+ const getVar = (varName: string): string => `var(${varName})`;
18
+
19
+ type ConsentManagerExpandedRowProps = {
20
+ group: DisplayProductGroup;
21
+ onModify: (consent: ConsentModalRecord) => void;
22
+ extraPurposes?: PurposeItem[];
23
+ hasMore?: boolean;
24
+ isLoadingMore?: boolean;
25
+ onLoadMore?: (group: DisplayProductGroup) => void;
26
+ };
27
+
28
+ const buildModalRecord = (
29
+ purpose: PurposeItem,
30
+ group: DisplayProductGroup
31
+ ): ConsentModalRecord => ({
32
+ ...purpose,
33
+ product_uuid: group.product_uuid,
34
+ product_name: group.product_name,
35
+ product_description: group.product_description,
36
+ nominator: group.nominator,
37
+ });
38
+
39
+ const headerCellStyle: React.CSSProperties = {
40
+ textAlign: 'left',
41
+ padding: '0.4375rem 0.875rem',
42
+ fontSize: '0.59375rem',
43
+ fontWeight: 600,
44
+ letterSpacing: '0.08em',
45
+ textTransform: 'uppercase',
46
+ color: getVar('--pc-text-tertiary'),
47
+ borderBottom: `1px solid ${getVar('--pc-border-color')}`,
48
+ whiteSpace: 'nowrap',
49
+ backgroundColor: getVar('--pc-bg-secondary'),
50
+ verticalAlign: 'middle',
51
+ };
52
+
53
+ const bodyCellStyle: React.CSSProperties = {
54
+ padding: '0.5rem 0.875rem',
55
+ fontSize: getVar('--pc-font-size-sm'),
56
+ color: getVar('--pc-text-primary'),
57
+ borderBottom: `1px solid ${getVar('--pc-border-color')}`,
58
+ verticalAlign: 'middle',
59
+ backgroundColor: getVar('--pc-bg-primary'),
60
+ };
61
+
62
+ export const ConsentManagerExpandedRow = ({
63
+ group,
64
+ onModify,
65
+ extraPurposes,
66
+ hasMore,
67
+ isLoadingMore,
68
+ onLoadMore,
69
+ }: ConsentManagerExpandedRowProps) => {
70
+ const { t } = useTranslation();
71
+ const purposes: PurposeItem[] = [...group.purposes, ...(extraPurposes ?? [])];
72
+
73
+ return (
74
+ <div
75
+ className="pc-consent-expanded-surface"
76
+ style={{
77
+ backgroundColor: getVar('--pc-bg-primary'),
78
+ // Keep expanded section visually nested under the product cell
79
+ // with extra breathing space for cleaner UI.
80
+ padding: '0.25rem 1rem 0.875rem 3.5rem',
81
+ }}
82
+ onClick={(e) => e.stopPropagation()}
83
+ >
84
+ <div
85
+ className="pc-consent-expanded-card"
86
+ style={{
87
+ border: `1px solid ${getVar('--pc-border-color')}`,
88
+ borderRadius: '0.375rem',
89
+ overflow: 'hidden',
90
+ backgroundColor: getVar('--pc-bg-primary'),
91
+ boxShadow: 'none',
92
+ }}
93
+ >
94
+ <table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
95
+ <colgroup>
96
+ <col style={{ width: '46%' }} />
97
+ <col style={{ width: '4%' }} />
98
+ <col style={{ width: '26%' }} />
99
+ <col style={{ width: '12%' }} />
100
+ <col style={{ width: '7%' }} />
101
+ <col style={{ width: '5%' }} />
102
+ </colgroup>
103
+ <thead>
104
+ <tr>
105
+ <th style={headerCellStyle}>{t('purpose')}</th>
106
+ <th style={headerCellStyle}>{t('status')}</th>
107
+ <th style={headerCellStyle}>{t('givenDate')}</th>
108
+ <th style={headerCellStyle}>{t('validTill')}</th>
109
+ <th style={headerCellStyle}>{t('method')}</th>
110
+ <th style={{ ...headerCellStyle, textAlign: 'right' }}>{t('action')}</th>
111
+ </tr>
112
+ </thead>
113
+ <tbody>
114
+ {purposes.map((purpose, idx) => {
115
+ const isLast = idx === purposes.length - 1;
116
+ const cellStyle = isLast ? { ...bodyCellStyle, borderBottom: 'none' } : bodyCellStyle;
117
+ const rowKey = purpose.purpose_uuid;
118
+ const modalRecord = buildModalRecord(purpose, group);
119
+ return (
120
+ <tr key={rowKey}>
121
+ <td style={cellStyle}>
122
+ <div style={{ ...pcStyles.flex, ...pcStyles.flexCol, gap: '0.125rem', minWidth: 0 }}>
123
+ <PCText size="sm" weight="semibold">{purpose.name}</PCText>
124
+ {purpose.description && (
125
+ <PCText
126
+ size="sm"
127
+ color="secondary"
128
+ style={{
129
+ display: '-webkit-box',
130
+ WebkitLineClamp: 1,
131
+ WebkitBoxOrient: 'vertical',
132
+ overflow: 'hidden',
133
+ wordBreak: 'break-word',
134
+ fontSize: '0.6875rem',
135
+ lineHeight: 1.2,
136
+ }}
137
+ title={purpose.description ?? undefined}
138
+ >
139
+ {purpose.description}
140
+ </PCText>
141
+ )}
142
+ </div>
143
+ </td>
144
+ <td style={cellStyle}>{renderStatusBadge(purpose.status, t)}</td>
145
+ <td style={cellStyle}>{renderGivenDate(purpose.given_date, { showTooltip: false })}</td>
146
+ <td style={cellStyle}>{renderValidTill(purpose, t)}</td>
147
+ <td style={cellStyle}>{renderMethod(purpose.method)}</td>
148
+ <td style={{ ...cellStyle, textAlign: 'right' }}>
149
+ {renderModifyButton(modalRecord, onModify, t)}
150
+ </td>
151
+ </tr>
152
+ );
153
+ })}
154
+ </tbody>
155
+ </table>
156
+ {hasMore && onLoadMore && (
157
+ <div
158
+ style={{
159
+ display: 'flex',
160
+ justifyContent: 'center',
161
+ padding: '0.5rem',
162
+ borderTop: `1px solid ${getVar('--pc-border-color')}`,
163
+ backgroundColor: getVar('--pc-bg-primary'),
164
+ }}
165
+ >
166
+ <PCButton
167
+ size="sm"
168
+ variant="outline"
169
+ onClick={() => onLoadMore(group)}
170
+ disabled={isLoadingMore}
171
+ >
172
+ {isLoadingMore ? t('loading') : t('loadMorePurposes')}
173
+ </PCButton>
174
+ </div>
175
+ )}
176
+ </div>
177
+ </div>
178
+ );
179
+ };