@redacto.io/consent-sdk-react 9.2.0-beta.0 → 9.2.0-beta.1

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 (51) hide show
  1. package/.turbo/turbo-build.log +15 -10
  2. package/CHANGELOG.md +6 -0
  3. package/dist/privacy-center.d.mts +56 -2
  4. package/dist/privacy-center.d.ts +56 -2
  5. package/dist/privacy-center.js +4213 -1256
  6. package/dist/privacy-center.mjs +4030 -1073
  7. package/package.json +1 -1
  8. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  9. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
  10. package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
  11. package/src/RedactoPrivacyCenter/api/client.ts +79 -0
  12. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
  13. package/src/RedactoPrivacyCenter/components/Form.tsx +828 -33
  14. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
  15. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +284 -71
  16. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
  17. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
  18. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
  19. package/src/RedactoPrivacyCenter/components/types.ts +26 -2
  20. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  21. package/src/RedactoPrivacyCenter/lib/api.ts +95 -1
  22. package/src/RedactoPrivacyCenter/lib/constants.ts +5 -0
  23. package/src/RedactoPrivacyCenter/lib/types.ts +105 -0
  24. package/src/RedactoPrivacyCenter/locales/as/translation.json +39 -1
  25. package/src/RedactoPrivacyCenter/locales/bho/translation.json +39 -1
  26. package/src/RedactoPrivacyCenter/locales/bn/translation.json +39 -1
  27. package/src/RedactoPrivacyCenter/locales/brx/translation.json +39 -1
  28. package/src/RedactoPrivacyCenter/locales/doi/translation.json +39 -1
  29. package/src/RedactoPrivacyCenter/locales/en/translation.json +44 -1
  30. package/src/RedactoPrivacyCenter/locales/gom/translation.json +39 -1
  31. package/src/RedactoPrivacyCenter/locales/gu/translation.json +39 -1
  32. package/src/RedactoPrivacyCenter/locales/hi/translation.json +39 -1
  33. package/src/RedactoPrivacyCenter/locales/kn/translation.json +39 -1
  34. package/src/RedactoPrivacyCenter/locales/ks/translation.json +39 -1
  35. package/src/RedactoPrivacyCenter/locales/mai/translation.json +39 -1
  36. package/src/RedactoPrivacyCenter/locales/ml/translation.json +39 -1
  37. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +39 -1
  38. package/src/RedactoPrivacyCenter/locales/mr/translation.json +39 -1
  39. package/src/RedactoPrivacyCenter/locales/ne/translation.json +39 -1
  40. package/src/RedactoPrivacyCenter/locales/or/translation.json +39 -1
  41. package/src/RedactoPrivacyCenter/locales/pa/translation.json +39 -1
  42. package/src/RedactoPrivacyCenter/locales/sa/translation.json +39 -1
  43. package/src/RedactoPrivacyCenter/locales/sat/translation.json +39 -1
  44. package/src/RedactoPrivacyCenter/locales/sd/translation.json +39 -1
  45. package/src/RedactoPrivacyCenter/locales/ta/translation.json +39 -1
  46. package/src/RedactoPrivacyCenter/locales/te/translation.json +39 -1
  47. package/src/RedactoPrivacyCenter/locales/ur/translation.json +39 -1
  48. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
  49. package/tests/Form.test.tsx +1170 -0
  50. package/tests/ProductSelectionSection.test.tsx +293 -0
  51. package/tests/RequestSubmit.test.tsx +295 -0
@@ -1,3 +1,4 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
1
2
  import character from '../assets/character.svg';
2
3
  import greenTick from '../assets/greenTick.svg';
3
4
  import sentImg from '../assets/sentImg.svg';
@@ -9,14 +10,234 @@ import { Mail } from 'lucide-react';
9
10
 
10
11
  const getVar = (varName: string): string => `var(${varName})`;
11
12
 
13
+ type SummaryCardProps = Readonly<{
14
+ children: ReactNode;
15
+ backgroundColor?: CSSProperties['backgroundColor'];
16
+ borderColor?: CSSProperties['borderColor'];
17
+ }>;
18
+
19
+ /**
20
+ * Shared visual container for the post-submit success / partial-success
21
+ * blocks. Same shape is used for the single Case-ID card, the multi-case
22
+ * list, and the failures list (with warning colors overriding the
23
+ * defaults).
24
+ */
25
+ const SummaryCard = ({
26
+ children,
27
+ backgroundColor,
28
+ borderColor,
29
+ }: SummaryCardProps) => (
30
+ <div
31
+ style={{
32
+ marginTop: pcStyles.mtMd.marginTop,
33
+ padding: pcStyles.pSm.padding,
34
+ ...(backgroundColor === undefined
35
+ ? pcStyles.bgSecondary
36
+ : { backgroundColor }),
37
+ borderRadius: pcStyles.borderRadiusMd,
38
+ border: `1px solid ${borderColor ?? pcStyles.borderColor}`,
39
+ }}
40
+ >
41
+ {children}
42
+ </div>
43
+ );
44
+
45
+ /**
46
+ * Success / partial-success surface shown after a case (or batch of cases)
47
+ * is submitted. See `RequestSubmitProps` for the shape contract.
48
+ *
49
+ * Rendering rules:
50
+ * - Exactly one succeeded entry + no failures → single Case-ID card
51
+ * (same look as the pre-P2-SDK-4 behaviour). Used by legacy single-
52
+ * case paths (nomination / grievance / non-grouped DSAR fallback).
53
+ * - More than one succeeded entry → list of `product_name: case_id`
54
+ * rows under a "{n} cases created" header.
55
+ * - Any failed entries → a separate "Some requests failed" block with
56
+ * a per-row Retry button that calls `onRetry(product_uuid)`.
57
+ */
12
58
  export default function RequestSubmit({
13
- caseId,
59
+ result,
14
60
  contact,
15
61
  onCreateRequest,
62
+ onRetry,
63
+ retryingGroupKeys,
16
64
  revokeConsentOnFulfilment,
17
65
  revokedPurposeNames,
18
66
  }: RequestSubmitProps) {
19
67
  const { t } = useTranslation();
68
+
69
+ const succeeded = result.succeeded;
70
+ const failed = result.failed;
71
+ const successCount = succeeded.length;
72
+ const hasFailures = failed.length > 0;
73
+ const isSingleCase = successCount === 1 && !hasFailures;
74
+
75
+ const renderCaseList = () => {
76
+ if (successCount === 0) {
77
+ return null;
78
+ }
79
+ if (isSingleCase) {
80
+ const only = succeeded[0];
81
+ return (
82
+ <SummaryCard>
83
+ {only.product_name !== '' && (
84
+ <PCText
85
+ size="sm"
86
+ weight="medium"
87
+ style={{
88
+ marginBottom: pcStyles.mbXs.marginBottom,
89
+ wordBreak: 'break-word',
90
+ }}
91
+ >
92
+ {only.product_name}
93
+ </PCText>
94
+ )}
95
+ <PCText
96
+ size="base"
97
+ weight="bold"
98
+ style={{
99
+ color: pcStyles.primary,
100
+ wordBreak: 'break-word',
101
+ marginBottom: pcStyles.mbXs.marginBottom,
102
+ }}
103
+ className="pc-text-responsive"
104
+ >
105
+ {t('caseID')}: {only.case_id}
106
+ </PCText>
107
+ <PCText size="xs" weight="medium" color="secondary">
108
+ {t('saveThisID')}.
109
+ </PCText>
110
+ </SummaryCard>
111
+ );
112
+ }
113
+ return (
114
+ <SummaryCard>
115
+ <PCText
116
+ size="sm"
117
+ weight="semibold"
118
+ style={{
119
+ color: pcStyles.primary,
120
+ marginBottom: pcStyles.mbSm.marginBottom,
121
+ }}
122
+ >
123
+ {t('casesCreatedCount', { count: successCount })}
124
+ </PCText>
125
+ <ul
126
+ style={{
127
+ margin: 0,
128
+ padding: 0,
129
+ listStyle: 'none',
130
+ display: 'flex',
131
+ flexDirection: 'column',
132
+ gap: getVar('--pc-spacing-xs'),
133
+ }}
134
+ >
135
+ {succeeded.map((entry) => (
136
+ <li
137
+ key={`${entry.group_key}:${entry.case_id}`}
138
+ style={{ wordBreak: 'break-word' }}
139
+ >
140
+ <PCText size="sm" weight="medium">
141
+ {entry.product_name}
142
+ </PCText>
143
+ <PCText
144
+ size="sm"
145
+ weight="bold"
146
+ style={{ color: pcStyles.primary, wordBreak: 'break-word' }}
147
+ >
148
+ {t('caseID')}: {entry.case_id}
149
+ </PCText>
150
+ </li>
151
+ ))}
152
+ </ul>
153
+ <PCText
154
+ size="xs"
155
+ weight="medium"
156
+ color="secondary"
157
+ style={{ marginTop: pcStyles.mtSm.marginTop }}
158
+ >
159
+ {t('saveThisID')}.
160
+ </PCText>
161
+ </SummaryCard>
162
+ );
163
+ };
164
+
165
+ const renderFailures = () => {
166
+ if (!hasFailures) {
167
+ return null;
168
+ }
169
+ return (
170
+ <SummaryCard
171
+ backgroundColor={pcStyles.warningBg}
172
+ borderColor={pcStyles.warningBorder}
173
+ >
174
+ <PCText
175
+ size="sm"
176
+ weight="semibold"
177
+ style={{
178
+ color: pcStyles.warningText,
179
+ marginBottom: pcStyles.mbSm.marginBottom,
180
+ }}
181
+ >
182
+ {t('someRequestsFailed')}
183
+ </PCText>
184
+ <ul
185
+ style={{
186
+ margin: 0,
187
+ padding: 0,
188
+ listStyle: 'none',
189
+ display: 'flex',
190
+ flexDirection: 'column',
191
+ gap: getVar('--pc-spacing-sm'),
192
+ }}
193
+ >
194
+ {failed.map((entry) => {
195
+ const isRetrying =
196
+ retryingGroupKeys?.has(entry.group_key) === true;
197
+ return (
198
+ <li
199
+ key={entry.group_key}
200
+ style={{
201
+ display: 'flex',
202
+ alignItems: 'flex-start',
203
+ justifyContent: 'space-between',
204
+ gap: getVar('--pc-spacing-sm'),
205
+ flexWrap: 'wrap',
206
+ }}
207
+ >
208
+ <div
209
+ style={{
210
+ minWidth: 0,
211
+ flex: 1,
212
+ wordBreak: 'break-word',
213
+ }}
214
+ >
215
+ <PCText size="sm" weight="medium">
216
+ {entry.product_name}
217
+ </PCText>
218
+ <PCText size="xs" weight="medium" color="secondary">
219
+ {entry.error}
220
+ </PCText>
221
+ </div>
222
+ {onRetry !== undefined && (
223
+ <PCButton
224
+ variant="outline"
225
+ size="sm"
226
+ onClick={() => onRetry(entry.group_key)}
227
+ disabled={isRetrying}
228
+ isLoading={isRetrying}
229
+ >
230
+ {isRetrying ? t('retrying') : t('retry')}
231
+ </PCButton>
232
+ )}
233
+ </li>
234
+ );
235
+ })}
236
+ </ul>
237
+ </SummaryCard>
238
+ );
239
+ };
240
+
20
241
  return (
21
242
  <div style={{ marginTop: pcStyles.mtMd.marginTop, position: 'relative' }}>
22
243
  <img
@@ -33,37 +254,47 @@ export default function RequestSubmit({
33
254
  filter: 'brightness(0) saturate(100%) invert(var(--pc-icon-filter, 0))',
34
255
  }}
35
256
  />
36
- <div style={{
37
- ...pcStyles.card,
38
- borderRadius: pcStyles.borderRadiusXl,
39
- padding: pcStyles.pLg.padding,
40
- position: 'relative',
41
- ...pcStyles.bgPrimary,
42
- border: `1px solid ${pcStyles.borderColor}`,
43
- }} className="pc-padding-responsive">
44
- <div style={{
45
- ...pcStyles.flex,
46
- ...pcStyles.flexCol,
47
- ...pcStyles.itemsStart,
48
- ...pcStyles.gapMd,
49
- }} className="pc-flex-responsive">
50
- <div style={{
257
+ <div
258
+ style={{
259
+ ...pcStyles.card,
260
+ borderRadius: pcStyles.borderRadiusXl,
261
+ padding: pcStyles.pLg.padding,
262
+ position: 'relative',
263
+ ...pcStyles.bgPrimary,
264
+ border: `1px solid ${pcStyles.borderColor}`,
265
+ }}
266
+ className="pc-padding-responsive"
267
+ >
268
+ <div
269
+ style={{
51
270
  ...pcStyles.flex,
271
+ ...pcStyles.flexCol,
52
272
  ...pcStyles.itemsStart,
53
273
  ...pcStyles.gapMd,
54
- width: '100%',
55
- }}>
56
- <div style={{
57
- height: '2.5rem',
58
- width: '2.5rem',
59
- flexShrink: 0,
60
- display: 'flex',
61
- alignItems: 'center',
62
- justifyContent: 'center',
63
- borderRadius: '50%',
64
- backgroundColor: `${getVar('--pc-success')}33`,
65
- padding: '0.375rem',
66
- }}>
274
+ }}
275
+ className="pc-flex-responsive"
276
+ >
277
+ <div
278
+ style={{
279
+ ...pcStyles.flex,
280
+ ...pcStyles.itemsStart,
281
+ ...pcStyles.gapMd,
282
+ width: '100%',
283
+ }}
284
+ >
285
+ <div
286
+ style={{
287
+ height: '2.5rem',
288
+ width: '2.5rem',
289
+ flexShrink: 0,
290
+ display: 'flex',
291
+ alignItems: 'center',
292
+ justifyContent: 'center',
293
+ borderRadius: '50%',
294
+ backgroundColor: `${getVar('--pc-success')}33`,
295
+ padding: '0.375rem',
296
+ }}
297
+ >
67
298
  <img
68
299
  src={greenTick}
69
300
  alt="success"
@@ -94,42 +325,19 @@ export default function RequestSubmit({
94
325
  >
95
326
  {t('thankYouForSubmittingYourRequest')}.
96
327
  </PCText>
97
- <div style={{
98
- marginTop: pcStyles.mtMd.marginTop,
99
- padding: pcStyles.pSm.padding,
100
- ...pcStyles.bgSecondary,
101
- borderRadius: pcStyles.borderRadiusMd,
102
- border: `1px solid ${pcStyles.borderColor}`,
103
- }}>
104
- <PCText
105
- size="base"
106
- weight="bold"
107
- style={{
108
- color: pcStyles.primary,
109
- wordBreak: 'break-word',
110
- marginBottom: pcStyles.mbXs.marginBottom,
111
- }}
112
- className="pc-text-responsive"
113
- >
114
- {t('caseID')}: {caseId}
115
- </PCText>
116
- <PCText
117
- size="xs"
118
- weight="medium"
119
- color="secondary"
120
- >
121
- {t('saveThisID')}.
122
- </PCText>
123
- </div>
124
- <div style={{
125
- ...pcStyles.flex,
126
- ...pcStyles.itemsStart,
127
- ...pcStyles.gapSm,
128
- marginTop: pcStyles.mtMd.marginTop,
129
- padding: '0.5rem 0.625rem',
130
- ...pcStyles.bgSecondary,
131
- borderRadius: pcStyles.borderRadiusMd,
132
- }}>
328
+ {renderCaseList()}
329
+ {renderFailures()}
330
+ <div
331
+ style={{
332
+ ...pcStyles.flex,
333
+ ...pcStyles.itemsStart,
334
+ ...pcStyles.gapSm,
335
+ marginTop: pcStyles.mtMd.marginTop,
336
+ padding: '0.5rem 0.625rem',
337
+ ...pcStyles.bgSecondary,
338
+ borderRadius: pcStyles.borderRadiusMd,
339
+ }}
340
+ >
133
341
  <Mail
134
342
  size={16}
135
343
  style={{
@@ -187,11 +395,13 @@ export default function RequestSubmit({
187
395
  opacity: 0.9,
188
396
  }}
189
397
  />
190
- <div style={{
191
- marginTop: pcStyles.mtMd.marginTop,
192
- ...pcStyles.flex,
193
- ...pcStyles.justifyCenter
194
- }}>
398
+ <div
399
+ style={{
400
+ marginTop: pcStyles.mtMd.marginTop,
401
+ ...pcStyles.flex,
402
+ ...pcStyles.justifyCenter,
403
+ }}
404
+ >
195
405
  <PCButton
196
406
  variant="primary"
197
407
  size="sm"
@@ -203,7 +413,10 @@ export default function RequestSubmit({
203
413
  </div>
204
414
  </div>
205
415
  </div>
206
- <div className="pc-block-xs pc-hidden-md" style={{ marginTop: pcStyles.mtMd.marginTop }}>
416
+ <div
417
+ className="pc-block-xs pc-hidden-md"
418
+ style={{ marginTop: pcStyles.mtMd.marginTop }}
419
+ >
207
420
  <div style={{ ...pcStyles.flex, ...pcStyles.justifyCenter }}>
208
421
  <PCButton
209
422
  variant="primary"
@@ -1,4 +1,4 @@
1
- import { Activity, MessageCircleWarning, ShieldCheck } from 'lucide-react';
1
+ import { Activity, MessageCircleWarning, ReceiptText, ShieldCheck } from 'lucide-react';
2
2
  import type { MenuItem } from './types';
3
3
 
4
4
  export const menuItemsPC: MenuItem[] = [
@@ -17,4 +17,9 @@ export const menuItemsPC: MenuItem[] = [
17
17
  href: 'activity',
18
18
  icon: <Activity size={18} strokeWidth={1.75} />,
19
19
  },
20
+ {
21
+ name: 'My Receipt',
22
+ href: 'receipts',
23
+ icon: <ReceiptText size={18} strokeWidth={1.75} />,
24
+ },
20
25
  ];
@@ -0,0 +1,56 @@
1
+ import type { DataElement, FormProductGroup, Purpose } from '../lib/types';
2
+
3
+ /**
4
+ * Build the per-purpose state used by `PurposeSelectionSection` for a single
5
+ * product group. Mirrors the legacy `data.purposes`-derived shape so the
6
+ * existing checkbox component works unchanged: data-elements default to
7
+ * `selected: false` and purposes are filtered to those with at least one
8
+ * data element.
9
+ *
10
+ * Lives in its own module so both `Form.tsx` (which seeds the per-group
11
+ * state Map) and `ProductSelectionSection.tsx` (which computes the
12
+ * activeCount shown on each product card) can share the same filter —
13
+ * otherwise the card header drifts from the list-body it summarises.
14
+ */
15
+ export const buildGroupPurposes = (group: FormProductGroup): Purpose[] =>
16
+ group.purposes
17
+ .filter((p): p is Purpose => p !== null && p !== undefined)
18
+ .map((p) => {
19
+ const validDataElements = Array.isArray(p.data_elements)
20
+ ? p.data_elements
21
+ .filter((de) => de !== null && de !== undefined && Boolean(de.uuid))
22
+ .map((de) => ({ ...de, selected: false }))
23
+ : [];
24
+ return {
25
+ ...p,
26
+ selected: false,
27
+ data_elements: validDataElements,
28
+ };
29
+ })
30
+ .filter((p) => p.data_elements.length > 0);
31
+
32
+ export type GroupCorrectionDataElement = DataElement & { purposeName: string };
33
+
34
+ /**
35
+ * Build a product-scoped deduped data-element list for grouped CORRECTION.
36
+ * Dedup is done by data-element UUID within the product group.
37
+ */
38
+ export const buildGroupDataElements = (
39
+ group: FormProductGroup,
40
+ ): GroupCorrectionDataElement[] => {
41
+ const byUuid = new Map<string, GroupCorrectionDataElement>();
42
+ for (const purpose of group.purposes) {
43
+ if (!purpose) {
44
+ continue;
45
+ }
46
+ const dataElements = Array.isArray(purpose.data_elements)
47
+ ? purpose.data_elements
48
+ : [];
49
+ for (const de of dataElements) {
50
+ if (de && de.uuid && !byUuid.has(de.uuid)) {
51
+ byUuid.set(de.uuid, { ...de, purposeName: purpose.name });
52
+ }
53
+ }
54
+ }
55
+ return Array.from(byUuid.values());
56
+ };
@@ -0,0 +1,203 @@
1
+ import { useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { createColumnHelper } from '@tanstack/react-table';
4
+ import dayjs from 'dayjs';
5
+ import { Download } from 'lucide-react';
6
+ import { PCText, PCButton, PCBadge } from '../ui';
7
+ import { pcStyles } from '../styles/pcStyles';
8
+ import { STATUS_COLORS } from '../lib/constants';
9
+ import { getVar } from '../lib/utils';
10
+ import type { Receipt } from '../lib/types';
11
+
12
+ const columnHelper = createColumnHelper<Receipt>();
13
+
14
+ type EventPalette = { color: string; bg: string };
15
+ type BadgeVariant = 'success' | 'error' | 'warning' | 'info';
16
+
17
+ // Map the raw event_type enum to a status palette + a fallback i18n label.
18
+ // The localized `event_type_display` from the API is preferred when present.
19
+ const EVENT_PALETTE: Record<string, EventPalette> = {
20
+ GRANTED: STATUS_COLORS.success,
21
+ WITHDRAWN: STATUS_COLORS.warning,
22
+ DECLINED: STATUS_COLORS.error,
23
+ EXPIRED: STATUS_COLORS.warning,
24
+ };
25
+
26
+ const EVENT_LABEL_KEY: Record<string, string> = {
27
+ GRANTED: 'consentGranted',
28
+ WITHDRAWN: 'consentRevoked',
29
+ DECLINED: 'consentDeclined',
30
+ EXPIRED: 'consentExpired',
31
+ };
32
+
33
+ const FALLBACK_PALETTE: EventPalette = STATUS_COLORS.info;
34
+ const EVENT_BADGE_VARIANT: Record<string, BadgeVariant> = {
35
+ GRANTED: 'success',
36
+ WITHDRAWN: 'warning',
37
+ DECLINED: 'error',
38
+ EXPIRED: 'warning',
39
+ };
40
+
41
+ // First two alphanumerics of the product name, e.g. "Website" → "WE", "Mobile App" → "MO".
42
+ function getProductInitials(name: string): string {
43
+ const cleaned = name.replace(/[^a-zA-Z0-9]/g, '');
44
+ return cleaned.slice(0, 2).toUpperCase() || '?';
45
+ }
46
+
47
+ const cellPad = { paddingTop: pcStyles.pySm.paddingTop, paddingBottom: pcStyles.pySm.paddingBottom };
48
+
49
+ export const useReceiptColumns = ({
50
+ onDownload,
51
+ downloadingUuid,
52
+ }: {
53
+ onDownload: (receipt: Receipt) => void;
54
+ downloadingUuid: string | null;
55
+ }) => {
56
+ const { t } = useTranslation();
57
+ return useMemo(
58
+ () => [
59
+ columnHelper.accessor('event_type', {
60
+ id: 'event',
61
+ header: t('event'),
62
+ cell: (info) => {
63
+ const row = info.row.original;
64
+ const raw = String(row.event_type ?? '').toUpperCase();
65
+ const palette = EVENT_PALETTE[raw] ?? FALLBACK_PALETTE;
66
+ const badgeVariant = EVENT_BADGE_VARIANT[raw] ?? 'info';
67
+ const labelKey = EVENT_LABEL_KEY[raw];
68
+ const label = row.event_type_display || (labelKey ? t(labelKey) : row.event_type || '—');
69
+ return (
70
+ <div style={cellPad}>
71
+ <PCBadge
72
+ variant={badgeVariant}
73
+ size="sm"
74
+ style={{
75
+ display: 'inline-flex',
76
+ alignItems: 'center',
77
+ gap: '0.4375rem',
78
+ fontWeight: 600,
79
+ whiteSpace: 'nowrap',
80
+ }}
81
+ >
82
+ <span
83
+ aria-hidden="true"
84
+ style={{
85
+ width: 7,
86
+ height: 7,
87
+ borderRadius: '50%',
88
+ backgroundColor: palette.color,
89
+ flexShrink: 0,
90
+ }}
91
+ />
92
+ {label}
93
+ </PCBadge>
94
+ </div>
95
+ );
96
+ },
97
+ enableSorting: false,
98
+ enableColumnFilter: false,
99
+ }),
100
+ columnHelper.accessor('product_name', {
101
+ id: 'product',
102
+ header: t('product'),
103
+ cell: (info) => {
104
+ const row = info.row.original;
105
+ const name = row.product_name_display || row.product_name || '—';
106
+ return (
107
+ <div style={{ ...cellPad, display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
108
+ <span
109
+ aria-hidden="true"
110
+ style={{
111
+ display: 'inline-flex',
112
+ alignItems: 'center',
113
+ justifyContent: 'center',
114
+ width: 32,
115
+ height: 32,
116
+ borderRadius: 8,
117
+ backgroundColor: getVar('--pc-bg-tertiary'),
118
+ color: getVar('--pc-text-secondary'),
119
+ border: `1px solid ${getVar('--pc-border-color')}`,
120
+ fontSize: '0.6875rem',
121
+ fontWeight: 600,
122
+ flexShrink: 0,
123
+ }}
124
+ >
125
+ {getProductInitials(name)}
126
+ </span>
127
+ <PCText size="sm" weight="medium" color="primary">
128
+ {name}
129
+ </PCText>
130
+ </div>
131
+ );
132
+ },
133
+ enableSorting: false,
134
+ enableColumnFilter: false,
135
+ }),
136
+ columnHelper.accessor('created_at', {
137
+ id: 'date',
138
+ header: t('date'),
139
+ cell: (info) => {
140
+ const d = dayjs(info.getValue());
141
+ return (
142
+ <div style={cellPad}>
143
+ <PCText size="sm" color="primary" weight="medium">
144
+ {d.isValid() ? d.format('MMM D, YYYY') : '—'}
145
+ </PCText>
146
+ </div>
147
+ );
148
+ },
149
+ enableSorting: false,
150
+ enableColumnFilter: false,
151
+ }),
152
+ columnHelper.display({
153
+ id: 'time',
154
+ header: t('time'),
155
+ cell: (info) => {
156
+ const d = dayjs(info.row.original.created_at);
157
+ return (
158
+ <div style={cellPad}>
159
+ <PCText size="sm" color="secondary">
160
+ {d.isValid() ? d.format('h:mm A') : '—'}
161
+ </PCText>
162
+ </div>
163
+ );
164
+ },
165
+ enableSorting: false,
166
+ enableColumnFilter: false,
167
+ }),
168
+ columnHelper.display({
169
+ id: 'download',
170
+ header: t('download'),
171
+ meta: { align: 'right' },
172
+ cell: (info) => {
173
+ const row = info.row.original;
174
+ const isDownloading = downloadingUuid === row.uuid;
175
+ return (
176
+ <div style={{ ...cellPad, display: 'flex', justifyContent: 'flex-end' }}>
177
+ <PCButton
178
+ variant="outline"
179
+ size="sm"
180
+ isLoading={isDownloading}
181
+ disabled={isDownloading || !row.uuid}
182
+ onClick={() => onDownload(row)}
183
+ style={{
184
+ display: 'inline-flex',
185
+ alignItems: 'center',
186
+ gap: '0.375rem',
187
+ minWidth: '6.75rem',
188
+ justifyContent: 'center',
189
+ }}
190
+ >
191
+ {!isDownloading && <Download size={15} strokeWidth={1.9} />}
192
+ {t('download')}
193
+ </PCButton>
194
+ </div>
195
+ );
196
+ },
197
+ enableSorting: false,
198
+ enableColumnFilter: false,
199
+ }),
200
+ ],
201
+ [t, onDownload, downloadingUuid]
202
+ );
203
+ };