@redacto.io/consent-sdk-react 9.2.0-beta.0 → 9.2.0-beta.2
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.
- package/.turbo/turbo-build.log +15 -10
- package/CHANGELOG.md +12 -0
- package/dist/privacy-center.d.mts +51 -1
- package/dist/privacy-center.d.ts +51 -1
- package/dist/privacy-center.js +3463 -1148
- package/dist/privacy-center.mjs +3399 -1084
- package/package.json +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
- package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
- package/src/RedactoPrivacyCenter/api/client.ts +79 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
- package/src/RedactoPrivacyCenter/components/Form.tsx +402 -259
- package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +95 -57
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
- package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
- package/src/RedactoPrivacyCenter/components/types.ts +20 -2
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
- package/src/RedactoPrivacyCenter/lib/api.ts +67 -8
- package/src/RedactoPrivacyCenter/lib/constants.ts +5 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +74 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +44 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +39 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
- package/tests/Form.test.tsx +1144 -0
- package/tests/ProductSelectionSection.test.tsx +328 -0
- package/tests/RequestSubmit.test.tsx +135 -0
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { useInternalNavigation } from './context/NavigationContext';
|
|
|
4
4
|
import { PrivacyCenterConsentManagerPage } from './PrivacyCenterConsentManagerPage';
|
|
5
5
|
import { PrivacyCenterFormPage } from './PrivacyCenterFormPage';
|
|
6
6
|
import { PrivacyCenterActivityPage } from './PrivacyCenterActivityPage';
|
|
7
|
+
import { PrivacyCenterMyReceiptPage } from './PrivacyCenterMyReceiptPage';
|
|
7
8
|
import { PrivacyCenterCaseDetailsPage } from './PrivacyCenterCaseDetailsPage';
|
|
8
9
|
import { NotificationsPage } from './NotificationsPage';
|
|
9
10
|
|
|
@@ -23,6 +24,8 @@ export function PrivacyCenterHomePage() {
|
|
|
23
24
|
return <PrivacyCenterFormPage />;
|
|
24
25
|
case 'activity':
|
|
25
26
|
return <PrivacyCenterActivityPage />;
|
|
27
|
+
case 'receipts':
|
|
28
|
+
return <PrivacyCenterMyReceiptPage />;
|
|
26
29
|
case 'case-details':
|
|
27
30
|
return <PrivacyCenterCaseDetailsPage />;
|
|
28
31
|
case 'notifications':
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import i18n from './lib/i18n';
|
|
3
|
+
import ReceiptsList from './components/ReceiptsList';
|
|
4
|
+
|
|
5
|
+
export function PrivacyCenterMyReceiptPage() {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!i18n.language) {
|
|
8
|
+
i18n.changeLanguage('en');
|
|
9
|
+
}
|
|
10
|
+
}, []);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
style={{
|
|
15
|
+
width: '100%',
|
|
16
|
+
display: 'flex',
|
|
17
|
+
flexDirection: 'column',
|
|
18
|
+
gap: '0.5rem',
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
<div style={{ paddingTop: '0.25rem' }}>
|
|
22
|
+
<ReceiptsList />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -419,6 +419,72 @@ export async function fetchActivitiesServer(params: {
|
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
export async function fetchReceiptsServer(params: {
|
|
423
|
+
organizationId: string;
|
|
424
|
+
workspaceId: string;
|
|
425
|
+
skip?: number;
|
|
426
|
+
limit?: number;
|
|
427
|
+
notice_uuid?: string;
|
|
428
|
+
event_type?: string;
|
|
429
|
+
status?: string;
|
|
430
|
+
created_after?: string;
|
|
431
|
+
created_before?: string;
|
|
432
|
+
language?: string;
|
|
433
|
+
authToken?: string;
|
|
434
|
+
consentClient?: ReturnType<typeof import('./client').createConsentClient>;
|
|
435
|
+
}): Promise<unknown | null> {
|
|
436
|
+
try {
|
|
437
|
+
if (!params.consentClient) {
|
|
438
|
+
throw new Error('Consent client is required');
|
|
439
|
+
}
|
|
440
|
+
// Privacy center endpoints use JWT Bearer tokens, not session-based auth
|
|
441
|
+
const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
|
|
442
|
+
|
|
443
|
+
const result = await params.consentClient.getReceipts(
|
|
444
|
+
baseRequest,
|
|
445
|
+
{
|
|
446
|
+
skip: params.skip,
|
|
447
|
+
limit: params.limit,
|
|
448
|
+
notice_uuid: params.notice_uuid,
|
|
449
|
+
event_type: params.event_type,
|
|
450
|
+
status: params.status,
|
|
451
|
+
created_after: params.created_after,
|
|
452
|
+
created_before: params.created_before,
|
|
453
|
+
language: params.language,
|
|
454
|
+
},
|
|
455
|
+
params.authToken
|
|
456
|
+
);
|
|
457
|
+
return result;
|
|
458
|
+
} catch (error: any) {
|
|
459
|
+
return handleServerActionError(error, 'fetching receipts');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function fetchReceiptPdfServer(params: {
|
|
464
|
+
organizationId: string;
|
|
465
|
+
workspaceId: string;
|
|
466
|
+
receiptUuid: string;
|
|
467
|
+
authToken?: string;
|
|
468
|
+
consentClient?: ReturnType<typeof import('./client').createConsentClient>;
|
|
469
|
+
}): Promise<Blob | null> {
|
|
470
|
+
try {
|
|
471
|
+
if (!params.consentClient) {
|
|
472
|
+
throw new Error('Consent client is required');
|
|
473
|
+
}
|
|
474
|
+
const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
|
|
475
|
+
|
|
476
|
+
const result = await params.consentClient.getReceiptPdf(
|
|
477
|
+
baseRequest,
|
|
478
|
+
params.receiptUuid,
|
|
479
|
+
params.authToken
|
|
480
|
+
);
|
|
481
|
+
return result;
|
|
482
|
+
} catch (error: any) {
|
|
483
|
+
// Re-throw 404s (receipt doesn't exist or not yours) so the UI can show a targeted message
|
|
484
|
+
return handleServerActionError(error, 'downloading receipt PDF', true);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
422
488
|
export async function fetchCaseHistoryServer(params: {
|
|
423
489
|
organizationId: string;
|
|
424
490
|
workspaceId: string;
|
|
@@ -464,6 +464,85 @@ export function createConsentClient(baseUrl: string) {
|
|
|
464
464
|
});
|
|
465
465
|
},
|
|
466
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Get receipts (privacy center) — consent receipts for the authenticated user
|
|
469
|
+
*/
|
|
470
|
+
async getReceipts(
|
|
471
|
+
baseRequest: BaseRequest,
|
|
472
|
+
params: {
|
|
473
|
+
skip?: number;
|
|
474
|
+
limit?: number;
|
|
475
|
+
notice_uuid?: string;
|
|
476
|
+
event_type?: string;
|
|
477
|
+
status?: string;
|
|
478
|
+
created_after?: string;
|
|
479
|
+
created_before?: string;
|
|
480
|
+
language?: string;
|
|
481
|
+
},
|
|
482
|
+
authToken?: string,
|
|
483
|
+
options?: Omit<ApiFetchOptions, "getAuthToken">
|
|
484
|
+
): Promise<unknown> {
|
|
485
|
+
const queryParams = new URLSearchParams();
|
|
486
|
+
if (params.skip !== undefined) queryParams.append("skip", params.skip.toString());
|
|
487
|
+
if (params.limit !== undefined) queryParams.append("limit", params.limit.toString());
|
|
488
|
+
if (params.notice_uuid) queryParams.append("notice_uuid", params.notice_uuid);
|
|
489
|
+
if (params.event_type) queryParams.append("event_type", params.event_type);
|
|
490
|
+
if (params.status) queryParams.append("status", params.status);
|
|
491
|
+
if (params.created_after) queryParams.append("created_after", params.created_after);
|
|
492
|
+
if (params.created_before) queryParams.append("created_before", params.created_before);
|
|
493
|
+
if (params.language) queryParams.append("language", params.language);
|
|
494
|
+
const queryString = queryParams.toString();
|
|
495
|
+
const url = privacyCenterUrl(baseRequest, `/receipts${queryString ? `?${queryString}` : ""}`);
|
|
496
|
+
return privacyCenterApiFetch<unknown>(url, {
|
|
497
|
+
...options,
|
|
498
|
+
method: "GET",
|
|
499
|
+
cache: "no-store",
|
|
500
|
+
authToken: resolveAuthToken(authToken, baseRequest),
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Download a single receipt as a PDF (privacy center).
|
|
506
|
+
* Returns raw PDF bytes as a Blob — the caller builds an object URL to open/save it.
|
|
507
|
+
* Uses a direct fetch (not privacyCenterApiFetch) because the response is binary, not JSON,
|
|
508
|
+
* and the Authorization header must be sent (anchor navigation wouldn't carry it).
|
|
509
|
+
*/
|
|
510
|
+
async getReceiptPdf(
|
|
511
|
+
baseRequest: BaseRequest,
|
|
512
|
+
receiptUuid: string,
|
|
513
|
+
authToken?: string
|
|
514
|
+
): Promise<Blob> {
|
|
515
|
+
const url = privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
|
|
516
|
+
const token = resolveAuthToken(authToken, baseRequest);
|
|
517
|
+
const requestHeaders = new Headers();
|
|
518
|
+
requestHeaders.set("Accept", "application/pdf");
|
|
519
|
+
if (token) {
|
|
520
|
+
const trimmedToken = token.trim();
|
|
521
|
+
requestHeaders.set(
|
|
522
|
+
"Authorization",
|
|
523
|
+
trimmedToken.startsWith("Bearer ") ? trimmedToken : `Bearer ${trimmedToken}`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const response = await fetch(url, { method: "GET", headers: requestHeaders });
|
|
527
|
+
if (!response.ok) {
|
|
528
|
+
let errorMessage = `Request failed with status ${response.status}`;
|
|
529
|
+
try {
|
|
530
|
+
const errorData = (await response.json()) as { detail?: unknown; error_code?: string } | null;
|
|
531
|
+
if (errorData?.detail) {
|
|
532
|
+
errorMessage =
|
|
533
|
+
typeof errorData.detail === "string" ? errorData.detail : JSON.stringify(errorData.detail);
|
|
534
|
+
} else if (errorData?.error_code) {
|
|
535
|
+
errorMessage = errorData.error_code;
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
// non-JSON error body — keep default message
|
|
539
|
+
}
|
|
540
|
+
// Prepend HTTP status so callers can branch on it (e.g. 404 = receipt not found)
|
|
541
|
+
throw new Error(`[${response.status}] ${errorMessage}`);
|
|
542
|
+
}
|
|
543
|
+
return response.blob();
|
|
544
|
+
},
|
|
545
|
+
|
|
467
546
|
/**
|
|
468
547
|
* Get case history (privacy center)
|
|
469
548
|
*/
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { type CSSProperties, useEffect } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { PCEmpty } from '../../ui/PCEmpty';
|
|
4
|
+
import { pcStyles } from '../../styles/pcStyles';
|
|
5
|
+
import type { FormProductGroup } from '../../lib/types';
|
|
6
|
+
import { buildGroupPurposes } from '../groupHelpers';
|
|
7
|
+
|
|
8
|
+
const getVar = (varName: string): string => `var(${varName})`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute the unique selection key for a product group.
|
|
12
|
+
*
|
|
13
|
+
* Direct (own-data) groups key on `product_uuid`. Nominated groups append
|
|
14
|
+
* the nominator UUID so two chips on the same product — one direct and one
|
|
15
|
+
* via a nominator — register as distinct selections.
|
|
16
|
+
*/
|
|
17
|
+
export const productGroupKey = (group: FormProductGroup): string =>
|
|
18
|
+
group.nominator
|
|
19
|
+
? `${group.product_uuid}:${group.nominator.uuid}`
|
|
20
|
+
: group.product_uuid;
|
|
21
|
+
|
|
22
|
+
export type ProductSelectionSectionProps = Readonly<{
|
|
23
|
+
directGroups: FormProductGroup[];
|
|
24
|
+
nominatedGroups: FormProductGroup[];
|
|
25
|
+
selectedProductKey: string | null;
|
|
26
|
+
onSelect: (key: string | null, group: FormProductGroup | null) => void;
|
|
27
|
+
/** Optional human label of the request type, e.g. "access". Folded into
|
|
28
|
+
* the instruction header when supplied. */
|
|
29
|
+
requestTypeLabel?: string;
|
|
30
|
+
isTranslating?: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
type ChipProps = Readonly<{
|
|
34
|
+
group: FormProductGroup;
|
|
35
|
+
isSelected: boolean;
|
|
36
|
+
isLocked: boolean;
|
|
37
|
+
onSelect: (key: string | null, group: FormProductGroup | null) => void;
|
|
38
|
+
nominationSuffix?: string;
|
|
39
|
+
activePurposesLabel: string;
|
|
40
|
+
}>;
|
|
41
|
+
|
|
42
|
+
const productSelectHeaderStyle: CSSProperties = {
|
|
43
|
+
paddingLeft: pcStyles.pxMd.paddingLeft,
|
|
44
|
+
paddingRight: pcStyles.pxMd.paddingRight,
|
|
45
|
+
marginBottom: pcStyles.mbMd.marginBottom,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ProductChip = ({
|
|
49
|
+
group,
|
|
50
|
+
isSelected,
|
|
51
|
+
isLocked,
|
|
52
|
+
onSelect,
|
|
53
|
+
nominationSuffix,
|
|
54
|
+
activePurposesLabel,
|
|
55
|
+
}: ChipProps) => {
|
|
56
|
+
const key = productGroupKey(group);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
role="option"
|
|
62
|
+
aria-selected={isSelected}
|
|
63
|
+
disabled={isLocked}
|
|
64
|
+
title={group.product_name}
|
|
65
|
+
className={`pc-product-select-chip ${isSelected ? 'pc-product-select-chip--selected' : ''}`}
|
|
66
|
+
data-testid={`product-chip-${key}`}
|
|
67
|
+
onClick={() => {
|
|
68
|
+
if (isLocked) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (isSelected) {
|
|
72
|
+
onSelect(null, null);
|
|
73
|
+
} else {
|
|
74
|
+
onSelect(key, group);
|
|
75
|
+
}
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<span className="pc-product-select-chip-inner">
|
|
79
|
+
<span className="pc-product-select-chip-label">{group.product_name}</span>
|
|
80
|
+
{nominationSuffix && (
|
|
81
|
+
<span className="pc-product-select-chip-suffix">{nominationSuffix}</span>
|
|
82
|
+
)}
|
|
83
|
+
<span className="pc-product-select-chip-meta">{activePurposesLabel}</span>
|
|
84
|
+
</span>
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const SkeletonChip = () => (
|
|
90
|
+
<div
|
|
91
|
+
className="pc-product-select-chip pc-product-select-chip--skeleton"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
style={{
|
|
96
|
+
height: '0.875rem',
|
|
97
|
+
width: '6rem',
|
|
98
|
+
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
|
99
|
+
borderRadius: pcStyles.borderRadiusMd,
|
|
100
|
+
backgroundColor: getVar('--pc-bg-tertiary'),
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
export function ProductSelectionSection({
|
|
107
|
+
directGroups,
|
|
108
|
+
nominatedGroups,
|
|
109
|
+
selectedProductKey,
|
|
110
|
+
onSelect,
|
|
111
|
+
requestTypeLabel,
|
|
112
|
+
isTranslating = false,
|
|
113
|
+
}: ProductSelectionSectionProps) {
|
|
114
|
+
const { t } = useTranslation();
|
|
115
|
+
|
|
116
|
+
const totalGroups = directGroups.length + nominatedGroups.length;
|
|
117
|
+
const isSingleGroup = totalGroups === 1;
|
|
118
|
+
const singleGroup = isSingleGroup
|
|
119
|
+
? (directGroups[0] ?? nominatedGroups[0] ?? null)
|
|
120
|
+
: null;
|
|
121
|
+
|
|
122
|
+
// Auto-select the only group when present. This drives the parent's
|
|
123
|
+
// selection state without requiring a click — per spec, single-group
|
|
124
|
+
// step is pre-selected and rendered read-only.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!singleGroup) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const key = productGroupKey(singleGroup);
|
|
130
|
+
if (selectedProductKey !== key) {
|
|
131
|
+
onSelect(key, singleGroup);
|
|
132
|
+
}
|
|
133
|
+
}, [singleGroup, selectedProductKey, onSelect]);
|
|
134
|
+
|
|
135
|
+
if (isTranslating) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="pc-product-select-section">
|
|
138
|
+
<div
|
|
139
|
+
className="pc-product-select-chips"
|
|
140
|
+
data-testid="product-selection-chips"
|
|
141
|
+
role="listbox"
|
|
142
|
+
aria-label={t('selectProductsForRequestGeneric')}
|
|
143
|
+
>
|
|
144
|
+
{[0, 1, 2].map((i) => (
|
|
145
|
+
<SkeletonChip key={i} />
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (totalGroups === 0) {
|
|
153
|
+
return (
|
|
154
|
+
<PCEmpty
|
|
155
|
+
title={t('noProductsAvailable')}
|
|
156
|
+
description={t('noProductsAvailableDescription')}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const headerText = requestTypeLabel
|
|
162
|
+
? t('selectProductsForRequest', { requestType: requestTypeLabel })
|
|
163
|
+
: t('selectProductsForRequestGeneric');
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className="pc-product-select-section">
|
|
167
|
+
<div style={productSelectHeaderStyle} className="pc-product-select-header">
|
|
168
|
+
<p
|
|
169
|
+
style={{
|
|
170
|
+
margin: 0,
|
|
171
|
+
fontSize: pcStyles.textXs.fontSize,
|
|
172
|
+
color: getVar('--pc-text-secondary'),
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{headerText}
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div
|
|
180
|
+
className="pc-product-select-chips"
|
|
181
|
+
data-testid="product-selection-chips"
|
|
182
|
+
role="listbox"
|
|
183
|
+
aria-label={headerText}
|
|
184
|
+
>
|
|
185
|
+
{directGroups.map((group) => {
|
|
186
|
+
const key = productGroupKey(group);
|
|
187
|
+
const purposeCount = buildGroupPurposes(group).length;
|
|
188
|
+
return (
|
|
189
|
+
<ProductChip
|
|
190
|
+
key={key}
|
|
191
|
+
group={group}
|
|
192
|
+
isSelected={selectedProductKey === key || isSingleGroup}
|
|
193
|
+
isLocked={isSingleGroup}
|
|
194
|
+
onSelect={onSelect}
|
|
195
|
+
activePurposesLabel={t('activePurposes', { count: purposeCount })}
|
|
196
|
+
/>
|
|
197
|
+
);
|
|
198
|
+
})}
|
|
199
|
+
|
|
200
|
+
{nominatedGroups.map((group) => {
|
|
201
|
+
const key = productGroupKey(group);
|
|
202
|
+
const purposeCount = buildGroupPurposes(group).length;
|
|
203
|
+
const nominatorLabel =
|
|
204
|
+
group.nominator?.name ?? group.nominator?.email ?? '';
|
|
205
|
+
return (
|
|
206
|
+
<ProductChip
|
|
207
|
+
key={key}
|
|
208
|
+
group={group}
|
|
209
|
+
isSelected={selectedProductKey === key || isSingleGroup}
|
|
210
|
+
isLocked={isSingleGroup}
|
|
211
|
+
onSelect={onSelect}
|
|
212
|
+
nominationSuffix={t('nominationChipSuffix', {
|
|
213
|
+
nominator: nominatorLabel,
|
|
214
|
+
})}
|
|
215
|
+
activePurposesLabel={t('activePurposes', { count: purposeCount })}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|