@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
@@ -0,0 +1,492 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
4
+ import dayjs from 'dayjs';
5
+ import { CalendarDays, Filter } from 'lucide-react';
6
+ import { AdvancedTable, AdvancedTablePagination } from '../table';
7
+ import { PCSelect, PCInput, PCButton, PCText, type SelectOption } from '../ui';
8
+ import { pcStyles } from '../styles/pcStyles';
9
+ import { getVar } from '../lib/utils';
10
+ import { fetchReceipts, fetchReceiptPdf } from '../lib/api';
11
+ import { useOrganization } from '../context/OrganizationContext';
12
+ import { useAuth } from '../context/AuthContext';
13
+ import { useBaseUrl } from '../context/BaseUrlContext';
14
+ import { useCurrentLanguage } from '../lib/useCurrentLanguage';
15
+ import { toast } from '../utils/toast';
16
+ import type { Receipt, ReceiptEventType, ReceiptPurposeStatus } from '../lib/types';
17
+ import { useReceiptColumns } from './receiptColumns';
18
+
19
+ /**
20
+ * Normalize the (still-evolving) receipts response into rows + total.
21
+ * Defensive about the wrapper shape ({data} | {items} | array) and the total
22
+ * key so the table works regardless of the exact envelope the API returns.
23
+ */
24
+ function normalizeReceipts(resp: unknown): { rows: Receipt[]; total: number } {
25
+ if (!resp || typeof resp !== 'object') return { rows: [], total: 0 };
26
+ const r = resp as Record<string, unknown>;
27
+ const arr = (Array.isArray(resp)
28
+ ? resp
29
+ : [r.data, r.items, r.receipts, r.results].find(Array.isArray)) as unknown[] | undefined;
30
+ const rows: Receipt[] = (arr ?? []).map((row) => {
31
+ const rec = (row ?? {}) as Record<string, unknown>;
32
+ // The receipt id field name may vary — coerce it so downloads always work.
33
+ return { ...rec, uuid: (rec.uuid ?? rec.receipt_uuid ?? rec.id ?? '') as string } as Receipt;
34
+ });
35
+ const pag = (r.pagination ?? r.meta ?? {}) as Record<string, unknown>;
36
+ const totalRaw = pag.total_count ?? pag.total ?? r.total_count ?? r.total ?? r.count ?? rows.length;
37
+ const total = Number(totalRaw);
38
+ return { rows, total: Number.isFinite(total) ? total : rows.length };
39
+ }
40
+
41
+ /**
42
+ * Map the date filter (preset or custom range) to the API's inclusive
43
+ * created_after / created_before ISO datetimes.
44
+ */
45
+ function dateRangeParams(
46
+ preset: string,
47
+ from: string,
48
+ to: string
49
+ ): { created_after?: string; created_before?: string } {
50
+ if (preset === 'custom') {
51
+ return {
52
+ created_after: from ? dayjs(from).startOf('day').toISOString() : undefined,
53
+ created_before: to ? dayjs(to).endOf('day').toISOString() : undefined,
54
+ };
55
+ }
56
+ if (preset === '1') {
57
+ return { created_after: dayjs().startOf('day').toISOString() };
58
+ }
59
+ if (preset === '7' || preset === '30' || preset === '90') {
60
+ return { created_after: dayjs().subtract(Number(preset), 'day').startOf('day').toISOString() };
61
+ }
62
+ return {};
63
+ }
64
+
65
+ export default function ReceiptsList() {
66
+ const { t } = useTranslation();
67
+ const { organization } = useOrganization();
68
+ const { accessToken, refreshToken, onError, updateTokens } = useAuth();
69
+ const { consentClient } = useBaseUrl();
70
+ const { langParam } = useCurrentLanguage();
71
+
72
+ const organizationId = organization?.uuid;
73
+ const workspaceId = organization?.workspaces[0]?.uuid;
74
+
75
+ const authContext = useMemo(
76
+ () => ({ accessToken, refreshToken, onError, updateTokens }),
77
+ [accessToken, refreshToken, onError, updateTokens]
78
+ );
79
+
80
+ const [receipts, setReceipts] = useState<Receipt[]>([]);
81
+ const [totalCount, setTotalCount] = useState(0);
82
+ const [isLoading, setIsLoading] = useState(false);
83
+ const [currentPage, setCurrentPage] = useState(1);
84
+ const [pageSize, setPageSize] = useState(10);
85
+ const [downloadingUuid, setDownloadingUuid] = useState<string | null>(null);
86
+ // Keep showing prior rows while a new page loads (smooth pagination).
87
+ const [previousData, setPreviousData] = useState<Receipt[]>([]);
88
+ const [previousTotalCount, setPreviousTotalCount] = useState(0);
89
+
90
+ // ─── Filters ────────────────────────────────────────────────────────────────
91
+ const [eventType, setEventType] = useState<ReceiptEventType | ''>('');
92
+ const [status, setStatus] = useState<ReceiptPurposeStatus | ''>('');
93
+ const [datePreset, setDatePreset] = useState<string>(''); // '' | '1' | '7' | '30' | '90' | 'custom'
94
+ const [customFrom, setCustomFrom] = useState<string>('');
95
+ const [customTo, setCustomTo] = useState<string>('');
96
+
97
+ const eventTypeOptions = useMemo<SelectOption[]>(
98
+ () => [
99
+ { value: '', label: t('all') },
100
+ { value: 'GRANTED', label: t('consentGranted') },
101
+ { value: 'WITHDRAWN', label: t('consentRevoked') },
102
+ { value: 'DECLINED', label: t('consentDeclined') },
103
+ { value: 'EXPIRED', label: t('consentExpired') },
104
+ ],
105
+ [t]
106
+ );
107
+ const statusOptions = useMemo<SelectOption[]>(
108
+ () => [
109
+ { value: '', label: t('all') },
110
+ { value: 'CONSENTED', label: t('consented') },
111
+ { value: 'DECLINED', label: t('declined') },
112
+ { value: 'WITHDRAWN', label: t('withdrawn') },
113
+ ],
114
+ [t]
115
+ );
116
+ const dateOptions = useMemo<SelectOption[]>(
117
+ () => [
118
+ { value: '', label: t('allTime') },
119
+ { value: '1', label: t('today') },
120
+ { value: '7', label: t('last7Days') },
121
+ { value: '30', label: t('last30Days') },
122
+ { value: '90', label: t('last3Months') },
123
+ { value: 'custom', label: t('customRange') },
124
+ ],
125
+ [t]
126
+ );
127
+
128
+ const eventValue = eventTypeOptions.find((o) => o.value === eventType) ?? null;
129
+ const statusValue = statusOptions.find((o) => o.value === status) ?? null;
130
+ const dateValue = dateOptions.find((o) => o.value === datePreset) ?? null;
131
+ const hasActiveFilters = Boolean(eventType || status || datePreset);
132
+ const isCustomRangeInvalid = useMemo(
133
+ () =>
134
+ datePreset === 'custom' &&
135
+ Boolean(customFrom && customTo && dayjs(customFrom).isAfter(dayjs(customTo), 'day')),
136
+ [datePreset, customFrom, customTo]
137
+ );
138
+
139
+ const activeFilterLabels = useMemo(() => {
140
+ const filters: { id: string; label: string; value: string }[] = [];
141
+
142
+ if (eventType && eventValue?.label) {
143
+ filters.push({ id: 'event', label: t('event'), value: eventValue.label });
144
+ }
145
+ if (status && statusValue?.label) {
146
+ filters.push({ id: 'status', label: t('status'), value: statusValue.label });
147
+ }
148
+ if (datePreset && dateValue?.label) {
149
+ let dateLabel = dateValue.label;
150
+ if (datePreset === 'custom') {
151
+ const fromLabel = customFrom ? dayjs(customFrom).format('DD MMM YYYY') : t('from');
152
+ const toLabel = customTo ? dayjs(customTo).format('DD MMM YYYY') : t('to');
153
+ dateLabel = `${fromLabel} - ${toLabel}`;
154
+ }
155
+ filters.push({ id: 'dateRange', label: t('dateRange'), value: dateLabel });
156
+ }
157
+
158
+ return filters;
159
+ }, [eventType, eventValue, status, statusValue, datePreset, dateValue, customFrom, customTo, t]);
160
+
161
+ const handleEventChange = (opt: SelectOption | null) => {
162
+ setEventType((opt ? String(opt.value) : '') as ReceiptEventType | '');
163
+ setCurrentPage(1);
164
+ };
165
+ const handleStatusChange = (opt: SelectOption | null) => {
166
+ setStatus((opt ? String(opt.value) : '') as ReceiptPurposeStatus | '');
167
+ setCurrentPage(1);
168
+ };
169
+ const handleDateChange = (opt: SelectOption | null) => {
170
+ const nextDatePreset = opt ? String(opt.value) : '';
171
+ setDatePreset(nextDatePreset);
172
+ if (nextDatePreset !== 'custom') {
173
+ setCustomFrom('');
174
+ setCustomTo('');
175
+ }
176
+ setCurrentPage(1);
177
+ };
178
+ const handleClearFilters = () => {
179
+ setEventType('');
180
+ setStatus('');
181
+ setDatePreset('');
182
+ setCustomFrom('');
183
+ setCustomTo('');
184
+ setCurrentPage(1);
185
+ };
186
+
187
+ const handleGetReceipts = useCallback(
188
+ async (page: number = currentPage, size: number = pageSize) => {
189
+ if (!organizationId || !workspaceId) return;
190
+ if (isCustomRangeInvalid) return;
191
+ setIsLoading(true);
192
+ try {
193
+ const skip = (page - 1) * size;
194
+ const { created_after, created_before } = dateRangeParams(datePreset, customFrom, customTo);
195
+ const response = await fetchReceipts(
196
+ organizationId,
197
+ workspaceId,
198
+ {
199
+ skip,
200
+ limit: size,
201
+ event_type: eventType || undefined,
202
+ status: status || undefined,
203
+ created_after,
204
+ created_before,
205
+ language: langParam,
206
+ },
207
+ authContext,
208
+ consentClient
209
+ );
210
+ const { rows, total } = normalizeReceipts(response);
211
+ setReceipts(rows);
212
+ setTotalCount(total);
213
+ if (page !== currentPage) setCurrentPage(page);
214
+ if (size !== pageSize) setPageSize(size);
215
+ } catch (error) {
216
+ console.error('[My Receipt] failed to fetch receipts:', error);
217
+ setReceipts([]);
218
+ setTotalCount(0);
219
+ setPreviousData([]);
220
+ setPreviousTotalCount(0);
221
+ toast.error(t('failedToFetchReceipts'));
222
+ } finally {
223
+ setIsLoading(false);
224
+ }
225
+ },
226
+ [
227
+ organizationId,
228
+ workspaceId,
229
+ currentPage,
230
+ pageSize,
231
+ eventType,
232
+ status,
233
+ datePreset,
234
+ customFrom,
235
+ customTo,
236
+ isCustomRangeInvalid,
237
+ authContext,
238
+ consentClient,
239
+ langParam,
240
+ t,
241
+ ]
242
+ );
243
+
244
+ const handleDownload = useCallback(
245
+ async (receipt: Receipt) => {
246
+ if (!organizationId || !workspaceId || !receipt.uuid) return;
247
+ setDownloadingUuid(receipt.uuid);
248
+ try {
249
+ const blob = await fetchReceiptPdf(
250
+ organizationId,
251
+ workspaceId,
252
+ receipt.uuid,
253
+ authContext,
254
+ consentClient
255
+ );
256
+ if (!blob) throw new Error('No PDF returned');
257
+ const url = URL.createObjectURL(blob);
258
+ const link = document.createElement('a');
259
+ link.href = url;
260
+ link.download = `receipt-${receipt.uuid}.pdf`;
261
+ document.body.appendChild(link);
262
+ link.click();
263
+ link.remove();
264
+ URL.revokeObjectURL(url);
265
+ } catch (error) {
266
+ console.error('[My Receipt] failed to download receipt PDF:', error);
267
+ toast.error(t('failedToDownloadReceipt'));
268
+ } finally {
269
+ setDownloadingUuid(null);
270
+ }
271
+ },
272
+ [organizationId, workspaceId, authContext, consentClient, t]
273
+ );
274
+
275
+ const columns = useReceiptColumns({ onDownload: handleDownload, downloadingUuid });
276
+
277
+ // Preserve previous data when a new page starts loading.
278
+ useEffect(() => {
279
+ if (receipts.length > 0) {
280
+ setPreviousData(receipts);
281
+ setPreviousTotalCount(totalCount);
282
+ }
283
+ }, [receipts, totalCount]);
284
+
285
+ useEffect(() => {
286
+ void handleGetReceipts();
287
+ }, [organizationId, workspaceId, handleGetReceipts]);
288
+
289
+ const displayData = isLoading && receipts.length === 0 ? previousData : receipts;
290
+ const displayTotalCount = isLoading && totalCount === 0 ? previousTotalCount : totalCount;
291
+
292
+ const table = useReactTable<Receipt>({
293
+ data: displayData,
294
+ columns,
295
+ getCoreRowModel: getCoreRowModel(),
296
+ manualPagination: true,
297
+ pageCount: Math.ceil(displayTotalCount / pageSize) || 0,
298
+ state: {
299
+ pagination: {
300
+ pageIndex: currentPage - 1,
301
+ pageSize,
302
+ },
303
+ },
304
+ onPaginationChange: (updater) => {
305
+ const next =
306
+ typeof updater === 'function'
307
+ ? updater({ pageIndex: currentPage - 1, pageSize })
308
+ : updater;
309
+ const nextPage = (next as { pageIndex: number }).pageIndex + 1;
310
+ const nextSize = (next as { pageSize: number }).pageSize;
311
+ if (nextPage === currentPage && nextSize === pageSize) return;
312
+ setCurrentPage(nextPage);
313
+ setPageSize(nextSize);
314
+ void handleGetReceipts(nextPage, nextSize);
315
+ },
316
+ });
317
+
318
+ return (
319
+ <div
320
+ style={{
321
+ width: '100%',
322
+ display: 'flex',
323
+ flexDirection: 'column',
324
+ gap: getVar('--pc-spacing-md'),
325
+ }}
326
+ >
327
+ {/* Filters */}
328
+ <section className="pc-receipts-filters-card">
329
+ <div className="pc-receipts-filters-header">
330
+ <div style={{ minWidth: 0 }}>
331
+ <PCText
332
+ as="p"
333
+ size="sm"
334
+ weight="semibold"
335
+ style={{
336
+ margin: 0,
337
+ display: 'inline-flex',
338
+ alignItems: 'center',
339
+ gap: '0.375rem',
340
+ }}
341
+ >
342
+ <Filter size={13} strokeWidth={1.9} />
343
+ {t('filterReceipts')}
344
+ </PCText>
345
+ <PCText as="p" size="xs" color="secondary" style={{ margin: '0.1875rem 0 0' }}>
346
+ {t('receiptFiltersHint')}
347
+ </PCText>
348
+ </div>
349
+ <PCButton
350
+ size="sm"
351
+ variant="text"
352
+ disabled={!hasActiveFilters}
353
+ onClick={handleClearFilters}
354
+ style={{ whiteSpace: 'nowrap' }}
355
+ >
356
+ {t('clearFilters')}
357
+ </PCButton>
358
+ </div>
359
+
360
+ <div className="pc-receipts-filters-grid">
361
+ <PCSelect
362
+ className="pc-filter-select pc-receipts-filter-control"
363
+ size="sm"
364
+ label={t('event')}
365
+ placeholder={t('all')}
366
+ options={eventTypeOptions}
367
+ value={eventValue}
368
+ onChange={handleEventChange}
369
+ onClear={() => {
370
+ setEventType('');
371
+ setCurrentPage(1);
372
+ }}
373
+ clearable={Boolean(eventType)}
374
+ showSelectedCheck
375
+ dropdownMatchTriggerWidth
376
+ style={{ width: '100%' }}
377
+ />
378
+ <PCSelect
379
+ className="pc-filter-select pc-receipts-filter-control"
380
+ size="sm"
381
+ label={t('status')}
382
+ placeholder={t('all')}
383
+ options={statusOptions}
384
+ value={statusValue}
385
+ onChange={handleStatusChange}
386
+ onClear={() => {
387
+ setStatus('');
388
+ setCurrentPage(1);
389
+ }}
390
+ clearable={Boolean(status)}
391
+ showSelectedCheck
392
+ dropdownMatchTriggerWidth
393
+ style={{ width: '100%' }}
394
+ />
395
+ <PCSelect
396
+ className="pc-filter-select pc-receipts-filter-control"
397
+ size="sm"
398
+ label={t('dateRange')}
399
+ placeholder={t('allTime')}
400
+ prefix={<CalendarDays size={12} strokeWidth={1.8} />}
401
+ options={dateOptions}
402
+ value={dateValue}
403
+ onChange={handleDateChange}
404
+ onClear={() => {
405
+ setDatePreset('');
406
+ setCustomFrom('');
407
+ setCustomTo('');
408
+ setCurrentPage(1);
409
+ }}
410
+ clearable={Boolean(datePreset)}
411
+ showSelectedCheck
412
+ dropdownMatchTriggerWidth
413
+ style={{ width: '100%' }}
414
+ />
415
+ </div>
416
+
417
+ {datePreset === 'custom' && (
418
+ <div className="pc-receipt-date-range">
419
+ <div className="pc-receipt-date-field">
420
+ <PCInput
421
+ type="date"
422
+ size="sm"
423
+ label={t('from')}
424
+ aria-label={t('from')}
425
+ value={customFrom}
426
+ error={isCustomRangeInvalid}
427
+ max={customTo || undefined}
428
+ onChange={(e) => {
429
+ setCustomFrom(e.target.value);
430
+ setCurrentPage(1);
431
+ }}
432
+ />
433
+ </div>
434
+ <div className="pc-receipt-date-field">
435
+ <PCInput
436
+ type="date"
437
+ size="sm"
438
+ label={t('to')}
439
+ aria-label={t('to')}
440
+ value={customTo}
441
+ error={isCustomRangeInvalid}
442
+ min={customFrom || undefined}
443
+ onChange={(e) => {
444
+ setCustomTo(e.target.value);
445
+ setCurrentPage(1);
446
+ }}
447
+ />
448
+ </div>
449
+ </div>
450
+ )}
451
+
452
+ {datePreset === 'custom' && isCustomRangeInvalid && (
453
+ <PCText as="p" size="xs" className="pc-receipt-custom-hint">
454
+ {t('receiptDateRangeInvalid')}
455
+ </PCText>
456
+ )}
457
+
458
+ <div className="pc-receipts-summary-row">
459
+ <PCText as="p" size="xs" color="secondary" style={{ margin: 0 }}>
460
+ {t('totalRecords', { count: displayTotalCount })}
461
+ </PCText>
462
+ {activeFilterLabels.length > 0 && (
463
+ <div className="pc-receipts-active-chips" aria-label={t('filtersApplied')}>
464
+ {activeFilterLabels.map((item) => (
465
+ <span key={item.id} className="pc-receipts-filter-chip">
466
+ <span className="pc-receipts-filter-chip-label">{item.label}:</span>
467
+ <span>{item.value}</span>
468
+ </span>
469
+ ))}
470
+ </div>
471
+ )}
472
+ </div>
473
+ </section>
474
+
475
+ <AdvancedTable table={table} isLoading={isLoading && displayData.length === 0} />
476
+
477
+ <div
478
+ style={{
479
+ ...pcStyles.flex,
480
+ ...pcStyles.flexCol,
481
+ ...pcStyles.itemsCenter,
482
+ ...pcStyles.justifyBetween,
483
+ ...pcStyles.gapSm,
484
+ marginTop: '0.25rem',
485
+ }}
486
+ className="pc-flex-responsive"
487
+ >
488
+ <AdvancedTablePagination table={table} />
489
+ </div>
490
+ </div>
491
+ );
492
+ }