@redacto.io/consent-sdk-react 9.1.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 (64) hide show
  1. package/.turbo/turbo-build.log +10 -9
  2. package/CHANGELOG.md +12 -0
  3. package/dist/index.js +2 -4
  4. package/dist/index.mjs +2 -4
  5. package/dist/privacy-center.d.mts +56 -2
  6. package/dist/privacy-center.d.ts +56 -2
  7. package/dist/privacy-center.js +4840 -1874
  8. package/dist/privacy-center.mjs +5124 -2158
  9. package/package.json +1 -1
  10. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +2 -4
  11. package/src/RedactoNoticeConsent/api/types.ts +1 -0
  12. package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +3 -1
  13. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  14. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
  15. package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
  16. package/src/RedactoPrivacyCenter/api/client.ts +79 -0
  17. package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +19 -13
  18. package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +25 -0
  19. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +39 -30
  20. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +2 -0
  21. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +10 -3
  22. package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +21 -6
  23. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
  24. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +12 -0
  25. package/src/RedactoPrivacyCenter/components/Form.tsx +843 -162
  26. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
  27. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +284 -71
  28. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
  29. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
  30. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
  31. package/src/RedactoPrivacyCenter/components/types.ts +26 -2
  32. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  33. package/src/RedactoPrivacyCenter/lib/api.ts +95 -1
  34. package/src/RedactoPrivacyCenter/lib/constants.ts +17 -0
  35. package/src/RedactoPrivacyCenter/lib/types.ts +115 -0
  36. package/src/RedactoPrivacyCenter/locales/as/translation.json +40 -1
  37. package/src/RedactoPrivacyCenter/locales/bho/translation.json +40 -1
  38. package/src/RedactoPrivacyCenter/locales/bn/translation.json +40 -1
  39. package/src/RedactoPrivacyCenter/locales/brx/translation.json +40 -1
  40. package/src/RedactoPrivacyCenter/locales/doi/translation.json +40 -1
  41. package/src/RedactoPrivacyCenter/locales/en/translation.json +46 -1
  42. package/src/RedactoPrivacyCenter/locales/gom/translation.json +40 -1
  43. package/src/RedactoPrivacyCenter/locales/gu/translation.json +40 -1
  44. package/src/RedactoPrivacyCenter/locales/hi/translation.json +40 -1
  45. package/src/RedactoPrivacyCenter/locales/kn/translation.json +40 -1
  46. package/src/RedactoPrivacyCenter/locales/ks/translation.json +40 -1
  47. package/src/RedactoPrivacyCenter/locales/mai/translation.json +40 -1
  48. package/src/RedactoPrivacyCenter/locales/ml/translation.json +40 -1
  49. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +40 -1
  50. package/src/RedactoPrivacyCenter/locales/mr/translation.json +40 -1
  51. package/src/RedactoPrivacyCenter/locales/ne/translation.json +40 -1
  52. package/src/RedactoPrivacyCenter/locales/or/translation.json +40 -1
  53. package/src/RedactoPrivacyCenter/locales/pa/translation.json +40 -1
  54. package/src/RedactoPrivacyCenter/locales/sa/translation.json +40 -1
  55. package/src/RedactoPrivacyCenter/locales/sat/translation.json +40 -1
  56. package/src/RedactoPrivacyCenter/locales/sd/translation.json +40 -1
  57. package/src/RedactoPrivacyCenter/locales/ta/translation.json +40 -1
  58. package/src/RedactoPrivacyCenter/locales/te/translation.json +40 -1
  59. package/src/RedactoPrivacyCenter/locales/ur/translation.json +40 -1
  60. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
  61. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +3 -1
  62. package/tests/Form.test.tsx +1170 -0
  63. package/tests/ProductSelectionSection.test.tsx +293 -0
  64. package/tests/RequestSubmit.test.tsx +295 -0
@@ -8,10 +8,14 @@ import { useTranslation } from 'react-i18next';
8
8
  import { AiOutlineClose } from 'react-icons/ai';
9
9
  import { PiUploadBold } from 'react-icons/pi';
10
10
  import { toast } from '../utils/toast';
11
- import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCTextarea } from '../ui';
12
- import { PCModal } from '../ui/PCModal';
11
+ import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
12
+ import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
13
13
  import { pcStyles } from '../styles/pcStyles';
14
- import { createCaseRequest, getFormData } from '../lib/api';
14
+ import {
15
+ createCaseRequest,
16
+ createCaseRequestUnsafe,
17
+ getFormData,
18
+ } from '../lib/api';
15
19
  import { ensureValidToken } from '../lib/utils';
16
20
  import { useAuth } from '../context/AuthContext';
17
21
  import { useBaseUrl } from '../context/BaseUrlContext';
@@ -23,8 +27,11 @@ import { useOrganization } from '../context/OrganizationContext';
23
27
  import i18n from '../lib/i18n';
24
28
  import { getLangParam } from '../lib/useCurrentLanguage';
25
29
  import type {
30
+ BatchCaseSubmitResult,
31
+ CreateCaseResponse,
26
32
  DataElement,
27
33
  FormData,
34
+ FormProductGroup,
28
35
  GrievanceOptions,
29
36
  GrievanceTypeElement,
30
37
  GrievanceValue,
@@ -38,14 +45,74 @@ import { RequestTypeEnum } from '../lib/types';
38
45
  import CorrectionDataSection from './DataRequest/CorrectionDataSection';
39
46
  import GrievanceDataSection from './DataRequest/GrievanceDataSection';
40
47
  import NominationDataSection from './DataRequest/NominationDataSection';
48
+ import {
49
+ ProductSelectionSection,
50
+ productGroupKey,
51
+ } from './DataRequest/ProductSelectionSection';
41
52
  import PurposeSelectionSection from './DataRequest/PurposeSelectionSection';
42
53
  import RequestSubmit from './RequestSubmit';
43
54
  import type { CaseHistoryProps } from './types';
44
- import { FiAlertCircle, FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
45
- import { MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL } from './constant';
55
+ import { FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
46
56
 
47
57
  const getVar = (varName: string): string => `var(${varName})`;
48
58
 
59
+ /**
60
+ * Narrow shape of the `case_id`/`uuid` fields extracted from a
61
+ * `CreateCaseResponse` (or its unwrapped privacy-center variant). Both
62
+ * are stringly returned — empty strings mean "not present on the
63
+ * response" rather than `null`, which keeps the call sites simple.
64
+ */
65
+ type ExtractedCaseRef = { uuid: string; case_id: string };
66
+
67
+ /**
68
+ * The two response shapes `extractCaseId` walks before resolving an
69
+ * `ExtractedCaseRef`. Privacy-center returns the unwrapped detail;
70
+ * case-management wraps it in `{ code, status, detail }`.
71
+ */
72
+ type CaseIdContainer = { case_id?: string; uuid?: string };
73
+
74
+ /**
75
+ * Request types that engage the Phase 2 product-step + per-product picker.
76
+ * Nomination and grievance are org-level and skip the product step.
77
+ *
78
+ * ACCESS/ERASURE drive a per-product `PurposeSelectionSection`; CORRECTION
79
+ * drives a per-product `CorrectionDataSection` whose `dataElements` are
80
+ * scoped to the selected group (dedup within product, not across products).
81
+ */
82
+ const GROUPED_REQUEST_TYPES: ReadonlySet<RequestTypeEnum> = new Set([
83
+ RequestTypeEnum.ACCESS,
84
+ RequestTypeEnum.ERASURE,
85
+ RequestTypeEnum.CORRECTION,
86
+ ]);
87
+
88
+ const isGroupedRequestType = (
89
+ rt: RequestTypeEnum | null,
90
+ ): rt is RequestTypeEnum => rt !== null && GROUPED_REQUEST_TYPES.has(rt);
91
+
92
+ /**
93
+ * Pull the `case_id` out of a `createCaseRequest` response, handling both
94
+ * the privacy-center shape (unwrapped detail) and the case-management shape
95
+ * (wrapped { code, status, detail }).
96
+ */
97
+ const extractCaseId = (
98
+ response: CreateCaseResponse | null,
99
+ ): ExtractedCaseRef | null => {
100
+ if (!response) {
101
+ return null;
102
+ }
103
+ if ('detail' in response && response.detail) {
104
+ const detail = response.detail as CaseIdContainer;
105
+ if (detail.case_id || detail.uuid) {
106
+ return { case_id: detail.case_id ?? '', uuid: detail.uuid ?? '' };
107
+ }
108
+ }
109
+ const direct = response as unknown as CaseIdContainer;
110
+ if (direct.case_id || direct.uuid) {
111
+ return { case_id: direct.case_id ?? '', uuid: direct.uuid ?? '' };
112
+ }
113
+ return null;
114
+ };
115
+
49
116
  export default function Form({
50
117
  onCreateRequest,
51
118
  isPopup = false,
@@ -93,6 +160,18 @@ export default function Form({
93
160
  });
94
161
  const [erasurePurposes, setErasurePurposes] = useState<Purpose[]>([]);
95
162
  const [accessPurposes, setAccessPurposes] = useState<Purpose[]>([]);
163
+ // Phase 2 of TECH-3712. Set of product-group keys the user has selected on
164
+ // the new product-cards step. Keyed by `productGroupKey(group)` so a single
165
+ // product reached both directly and via a nominator stays distinct.
166
+ const [selectedProductKey, setSelectedProductKey] = useState<string | null>(
167
+ null,
168
+ );
169
+ // Phase 2 of TECH-3712. Per-product purpose state keyed by
170
+ // `productGroupKey(group)`. Each selected group keeps its own Purpose[]
171
+ // for the existing PurposeSelectionSection to drive.
172
+ const [purposesByGroupKey, setPurposesByGroupKey] = useState<
173
+ Map<string, Purpose[]>
174
+ >(new Map());
96
175
  const [selectedCorrectionElements, setSelectedCorrectionElements] = useState<
97
176
  string[]
98
177
  >([]);
@@ -102,7 +181,29 @@ export default function Form({
102
181
  const [correctionValues, setCorrectionValues] = useState<
103
182
  Record<string, { current: string; updated: string }>
104
183
  >({});
105
- const [caseId, setCaseId] = useState<string | null>(null);
184
+ // TECH-3712 (correction grouped path). Per-product correction selections
185
+ // keyed by `productGroupKey(group)`. Parallel to `purposesByGroupKey`
186
+ // for ACCESS/ERASURE — each selected group keeps its own list of chosen
187
+ // data-element names and the {current, updated} value pairs for them.
188
+ const [correctionElementsByGroupKey, setCorrectionElementsByGroupKey] =
189
+ useState<Map<string, string[]>>(new Map());
190
+ const [correctionValuesByGroupKey, setCorrectionValuesByGroupKey] = useState<
191
+ Map<string, Record<string, { current: string; updated: string }>>
192
+ >(new Map());
193
+ // P2-SDK-4 of TECH-3712. Holds the aggregated batch result that
194
+ // `RequestSubmit` renders on the success step. Both paths populate this:
195
+ // - Grouped DSAR: directly from `submitBatchCases`.
196
+ // - Legacy single-case: a synthetic single-entry result.
197
+ const [batchResult, setBatchResult] = useState<BatchCaseSubmitResult | null>(
198
+ null,
199
+ );
200
+ // P2-SDK-4 of TECH-3712. Product-group keys currently mid-retry. Drives
201
+ // the per-row spinner state on the failure list. Keyed by
202
+ // `productGroupKey(group)` (not `product_uuid`) so a product picked both
203
+ // directly and via a nominator can be retried independently.
204
+ const [retryingGroupKeys, setRetryingGroupKeys] = useState<Set<string>>(
205
+ new Set(),
206
+ );
106
207
  const fileInputRef = useRef<HTMLInputElement>(null);
107
208
  // Store the original English form data for submission — translations are display-only
108
209
  const englishDataRef = useRef<FormData | null>(null);
@@ -134,10 +235,6 @@ export default function Form({
134
235
  const [isTranslating, setIsTranslating] = useState(false);
135
236
  const [isRequestDetailsExpanded, setIsRequestDetailsExpanded] =
136
237
  useState(true);
137
- const [isErasureConsentModalOpen, setIsErasureConsentModalOpen] =
138
- useState(false);
139
- const [revokeConsentOnFulfilment, setRevokeConsentOnFulfilment] =
140
- useState(false);
141
238
 
142
239
  const resetFileInput = () => {
143
240
  if (fileInputRef.current) {
@@ -145,6 +242,122 @@ export default function Form({
145
242
  }
146
243
  };
147
244
 
245
+ // Phase 2 of TECH-3712. Backwards-compat guard: the new grouped flow only
246
+ // engages when the backend ships the `direct_groups` field (Phase 1). Older
247
+ // backends fall through to the legacy flat-purposes render below.
248
+ const useGroupedRender = useMemo<boolean>(
249
+ () => Array.isArray(data?.direct_groups),
250
+ [data],
251
+ );
252
+
253
+ // Phase 2 of TECH-3712. Surface a one-time warning per data load when we
254
+ // fall back to the legacy flat-purposes render. Helps customer developers
255
+ // notice their backend hasn't been upgraded to Phase 1 yet (the form still
256
+ // works, but the new product-scoped DSAR UX is unavailable).
257
+ useEffect(() => {
258
+ if (data && !useGroupedRender) {
259
+ console.warn(
260
+ '[consent-sdk] Backend response lacks direct_groups; ' +
261
+ 'falling back to legacy flat-purposes render. ' +
262
+ 'Update consent-server to Phase 1 to enable product-scoped DSAR.',
263
+ );
264
+ }
265
+ }, [data, useGroupedRender]);
266
+
267
+ // Phase 2 of TECH-3712. Union of direct + nominated product groups used to
268
+ // drive both the cards step and the per-product purpose-picker step.
269
+ const allGroups = useMemo<FormProductGroup[]>(() => {
270
+ if (!useGroupedRender || !data) {
271
+ return [];
272
+ }
273
+ return [...(data.direct_groups ?? []), ...(data.nominated_groups ?? [])];
274
+ }, [data, useGroupedRender]);
275
+
276
+ // TECH-3712 UX: when the DP has exactly one consent group total
277
+ // (direct + nominated combined), the product-selection step is friction —
278
+ // there's nothing to choose. Skip rendering ProductSelectionSection
279
+ // entirely and auto-select the lone group below so step 3 (per-product
280
+ // purpose picker) renders without an intermediate card. With zero groups
281
+ // we still render the section so the empty-state message is visible.
282
+ const totalGroups = useMemo<number>(
283
+ () =>
284
+ (data?.direct_groups?.length ?? 0) +
285
+ (data?.nominated_groups?.length ?? 0),
286
+ [data],
287
+ );
288
+ const skipProductStep = totalGroups === 1;
289
+
290
+ // Phase 2 of TECH-3712. Subset of groups currently checked on the cards
291
+ // step — drives which `PurposeSelectionSection`s render and which cases
292
+ // get POSTed on submit.
293
+ const selectedGroups = useMemo<FormProductGroup[]>(() => {
294
+ if (selectedProductKey) {
295
+ const group = allGroups.find(
296
+ (g) => productGroupKey(g) === selectedProductKey,
297
+ );
298
+ if (group) {
299
+ return [group];
300
+ }
301
+ }
302
+ if (skipProductStep) {
303
+ const onlyGroup =
304
+ data?.direct_groups?.[0] ?? data?.nominated_groups?.[0];
305
+ return onlyGroup ? [onlyGroup] : [];
306
+ }
307
+ return [];
308
+ }, [allGroups, selectedProductKey, skipProductStep, data]);
309
+
310
+ // Phase 2 of TECH-3712. Stable select handler for ProductSelectionSection.
311
+ // Single-group auto-select fires this on mount via the child's effect.
312
+ const handleProductSelect = useCallback(
313
+ (key: string | null, _group: FormProductGroup | null) => {
314
+ setSelectedProductKey(key);
315
+ },
316
+ [],
317
+ );
318
+
319
+ // Phase 2 of TECH-3712. Stable per-group setter passed to the wrapped
320
+ // PurposeSelectionSection so each card edits its own slice of state.
321
+ const setPurposesForGroup = useCallback(
322
+ (key: string, updated: Purpose[]) => {
323
+ setPurposesByGroupKey((prev) => {
324
+ const next = new Map(prev);
325
+ next.set(key, updated);
326
+ return next;
327
+ });
328
+ },
329
+ [],
330
+ );
331
+
332
+ // TECH-3712 (correction grouped path). Stable per-group setters for the
333
+ // CORRECTION maps. CorrectionDataSection's `setSelected` and
334
+ // `setCorrectionValues` props are value-setters (not state updaters), so
335
+ // these accept the full replacement value the child computed.
336
+ const setCorrectionElementsForGroup = useCallback(
337
+ (key: string, values: string[]) => {
338
+ setCorrectionElementsByGroupKey((prev) => {
339
+ const next = new Map(prev);
340
+ next.set(key, values);
341
+ return next;
342
+ });
343
+ },
344
+ [],
345
+ );
346
+
347
+ const setCorrectionValuesForGroup = useCallback(
348
+ (
349
+ key: string,
350
+ values: Record<string, { current: string; updated: string }>,
351
+ ) => {
352
+ setCorrectionValuesByGroupKey((prev) => {
353
+ const next = new Map(prev);
354
+ next.set(key, values);
355
+ return next;
356
+ });
357
+ },
358
+ [],
359
+ );
360
+
148
361
  useEffect(() => {
149
362
  if (data?.purposes) {
150
363
  // Validate purposes array structure
@@ -164,11 +377,13 @@ export default function Form({
164
377
 
165
378
  const uniqueElements = new Map();
166
379
  extractedPurposes.forEach((purpose) => {
167
- // Ensure data_elements is an array and filter out any null/undefined elements
168
- // Only include data elements with selected === true
380
+ // Ensure data_elements is an array and filter out any null/undefined elements.
381
+ // Include every data element the org holds for this user, regardless of current
382
+ // consent state. Revoking consent does not delete data (TECH-4089), so purposes
383
+ // with revoked/withdrawn consent must remain raisable for access/erasure/correction.
169
384
  const validDataElements = Array.isArray(purpose.data_elements)
170
385
  ? purpose.data_elements.filter(
171
- (element) => element !== null && element !== undefined && element.uuid && element.selected
386
+ (element) => element !== null && element !== undefined && element.uuid
172
387
  )
173
388
  : [];
174
389
 
@@ -188,13 +403,14 @@ export default function Form({
188
403
  Array.from(uniqueElements.values()) as DataElement[]
189
404
  );
190
405
 
191
- // Ensure data_elements are properly preserved with all properties
192
- // Only include data elements with selected === true, and filter out purposes with no data elements
406
+ // Ensure data_elements are properly preserved with all properties.
407
+ // Include data elements regardless of consent state (revocation ≠ deletion,
408
+ // TECH-4089); only drop purposes that genuinely have no data elements.
193
409
  const processedPurposes = extractedPurposes
194
410
  .map((p) => {
195
411
  const validDataElements = Array.isArray(p.data_elements)
196
412
  ? p.data_elements
197
- .filter((de) => de !== null && de !== undefined && de.selected)
413
+ .filter((de) => de !== null && de !== undefined && de.uuid)
198
414
  .map((de) => ({
199
415
  ...de,
200
416
  selected: false,
@@ -316,18 +532,28 @@ export default function Form({
316
532
  setSelectedCorrectionElements([]);
317
533
  setCorrectionValues({});
318
534
  setNominationData({ nominee_email: '', nominee_mobile: '' });
535
+ // Phase 2 of TECH-3712. Drop product-step state too so switching from
536
+ // access to erasure (or back) starts the cards step from scratch.
537
+ setSelectedProductKey(null);
538
+ setPurposesByGroupKey(new Map());
539
+ // TECH-3712 (correction grouped path). Drop the per-group correction
540
+ // maps too so switching into/out of CORRECTION doesn't carry stale
541
+ // selections from a previous request-type pick.
542
+ setCorrectionElementsByGroupKey(new Map());
543
+ setCorrectionValuesByGroupKey(new Map());
319
544
  if (data?.purposes && Array.isArray(data.purposes)) {
320
545
  const extractedPurposes = data.purposes
321
546
  .map((p) => p?.purpose)
322
547
  .filter((p) => p !== null && p !== undefined);
323
548
 
324
- // Ensure data_elements are properly preserved with all properties
325
- // Only include data elements with selected === true, and filter out purposes with no data elements
549
+ // Ensure data_elements are properly preserved with all properties.
550
+ // Include data elements regardless of consent state (revocation ≠ deletion,
551
+ // TECH-4089); only drop purposes that genuinely have no data elements.
326
552
  const processedPurposes = extractedPurposes
327
553
  .map((p) => {
328
554
  const validDataElements = Array.isArray(p.data_elements)
329
555
  ? p.data_elements
330
- .filter((de) => de !== null && de !== undefined && de.selected)
556
+ .filter((de) => de !== null && de !== undefined && de.uuid)
331
557
  .map((de) => ({
332
558
  ...de,
333
559
  selected: false,
@@ -361,6 +587,94 @@ export default function Form({
361
587
  }
362
588
  }, [requestType, data, grievanceOptions]);
363
589
 
590
+ // TECH-3712 UX: when `skipProductStep` is true (DP has exactly one consent
591
+ // group total), auto-select that group so step 3 (per-product purpose
592
+ // picker) renders directly without the user clicking a card. Placed AFTER
593
+ // the request-type-change clearing effect above so a request-type switch
594
+ // re-establishes the selection on the next tick — without this ordering,
595
+ // the clearing effect would wipe whatever this set on the same render.
596
+ // The child ProductSelectionSection has an equivalent mount-time effect,
597
+ // but that effect never fires here because the child is not rendered
598
+ // (see the JSX gate below).
599
+ useEffect(() => {
600
+ if (!skipProductStep) {
601
+ return;
602
+ }
603
+ const onlyGroup =
604
+ data?.direct_groups?.[0] ?? data?.nominated_groups?.[0];
605
+ if (!onlyGroup) {
606
+ return;
607
+ }
608
+ const key = productGroupKey(onlyGroup);
609
+ setSelectedProductKey((prev) => (prev === key ? prev : key));
610
+ }, [skipProductStep, data, requestType]);
611
+
612
+ // Phase 2 of TECH-3712. Initialise purpose state for newly-selected groups
613
+ // and prune entries for groups that have been deselected. Idempotent — only
614
+ // mutates when the keyset actually changes. Declared AFTER the request-type
615
+ // clearing effect so a type switch doesn't wipe freshly seeded entries.
616
+ useEffect(() => {
617
+ setPurposesByGroupKey((prev) => {
618
+ const next = new Map(prev);
619
+ const selectedKeys = new Set<string>();
620
+ for (const group of selectedGroups) {
621
+ const key = productGroupKey(group);
622
+ selectedKeys.add(key);
623
+ if (!next.has(key)) {
624
+ next.set(key, buildGroupPurposes(group));
625
+ }
626
+ }
627
+ for (const existingKey of Array.from(next.keys())) {
628
+ if (!selectedKeys.has(existingKey)) {
629
+ next.delete(existingKey);
630
+ }
631
+ }
632
+ return next;
633
+ });
634
+ }, [selectedGroups]);
635
+
636
+ // TECH-3712 (correction grouped path). Mirror the purpose-state effect
637
+ // for the CORRECTION per-group maps: seed empty slices for newly-selected
638
+ // groups and prune slices for deselected ones. Empty defaults keep the
639
+ // CorrectionDataSection signature unchanged (it expects a `string[]` and
640
+ // a `Record<string, {current, updated}>`).
641
+ useEffect(() => {
642
+ setCorrectionElementsByGroupKey((prev) => {
643
+ const next = new Map(prev);
644
+ const selectedKeys = new Set<string>();
645
+ for (const group of selectedGroups) {
646
+ const key = productGroupKey(group);
647
+ selectedKeys.add(key);
648
+ if (!next.has(key)) {
649
+ next.set(key, []);
650
+ }
651
+ }
652
+ for (const existingKey of Array.from(next.keys())) {
653
+ if (!selectedKeys.has(existingKey)) {
654
+ next.delete(existingKey);
655
+ }
656
+ }
657
+ return next;
658
+ });
659
+ setCorrectionValuesByGroupKey((prev) => {
660
+ const next = new Map(prev);
661
+ const selectedKeys = new Set<string>();
662
+ for (const group of selectedGroups) {
663
+ const key = productGroupKey(group);
664
+ selectedKeys.add(key);
665
+ if (!next.has(key)) {
666
+ next.set(key, {});
667
+ }
668
+ }
669
+ for (const existingKey of Array.from(next.keys())) {
670
+ if (!selectedKeys.has(existingKey)) {
671
+ next.delete(existingKey);
672
+ }
673
+ }
674
+ return next;
675
+ });
676
+ }, [selectedGroups]);
677
+
364
678
 
365
679
  // Fetch API translations when language changes (purposes, data elements, grievance options)
366
680
  useEffect(() => {
@@ -505,52 +819,60 @@ export default function Form({
505
819
  return translatedName;
506
820
  }, [data]);
507
821
 
508
- const selectedErasurePurposeList = useMemo(
509
- () =>
510
- erasurePurposes
511
- .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
512
- .map((p) => p.name)
513
- .filter(Boolean),
514
- [erasurePurposes]
515
- );
516
-
517
- const selectedErasurePurposeNames = useMemo(() => {
518
- if (
519
- selectedErasurePurposeList.length === 0 ||
520
- selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL
521
- ) {
522
- return t('revokeConsentModalYourPurposes');
523
- }
524
- return selectedErasurePurposeList.join(', ');
525
- }, [selectedErasurePurposeList, t]);
526
-
527
822
  const handleSubmitClick = () => {
528
- if (!isFormValid){
529
- return
530
- };
531
- if (requestType === RequestTypeEnum.ERASURE) {
532
- setIsErasureConsentModalOpen(true);
823
+ if (!isFormValid) {
533
824
  return;
534
825
  }
535
- setRevokeConsentOnFulfilment(false);
536
826
  void handleSubmit(false);
537
827
  };
538
828
 
539
- const handleErasureModalChoice = (revoke: boolean) => {
540
- setRevokeConsentOnFulfilment(revoke);
541
- setIsErasureConsentModalOpen(false);
542
- void handleSubmit(revoke);
543
- };
544
-
545
- const handleErasureModalClose = () => {
546
- setIsErasureConsentModalOpen(false);
547
- };
548
-
549
829
  const handleSubmit = async (revokeConsent: boolean = false) => {
550
- if (!isFormValid){
551
- return
552
- };
830
+ if (!isFormValid) {
831
+ return;
832
+ }
553
833
 
834
+ // Phase 2 of TECH-3712. Multi-product DSAR batch path: fan one
835
+ // createCaseRequest per selected product via Promise.allSettled so
836
+ // partial failures don't tank the whole submission.
837
+ if (useGroupedRender && isGroupedRequestType(requestType) && selectedGroups.length > 0) {
838
+ try {
839
+ setIsLoading(true);
840
+ const result = await submitBatchCases(revokeConsent);
841
+
842
+ if (result.succeeded.length > 0) {
843
+ queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
844
+ // P2-SDK-4 of TECH-3712. Thread the full BatchCaseSubmitResult
845
+ // through to RequestSubmit so the success surface can list every
846
+ // case_id and render per-card retry buttons for any failures.
847
+ setBatchResult(result);
848
+ setIsSubmitted(true);
849
+ if (result.failed.length === 0) {
850
+ toast.success(t('requestSubmittedSuccess'));
851
+ } else {
852
+ toast.error(t('requestSubmissionFailed'));
853
+ }
854
+ if (isPopup) {
855
+ onCreateRequest?.();
856
+ }
857
+ } else {
858
+ toast.error(t('requestSubmissionFailed'));
859
+ }
860
+ } catch (error) {
861
+ console.error('Error submitting batch request:', error);
862
+ toast.error(t('submissionFailed'));
863
+ } finally {
864
+ setIsLoading(false);
865
+ if (isPopup) {
866
+ onClose?.();
867
+ }
868
+ }
869
+ return;
870
+ }
871
+
872
+ // Legacy / non-DSAR path: a single createCaseRequest with org-level
873
+ // request_details. Untouched by Phase 2 — nomination and grievance are
874
+ // always org-level, and DSAR falls here only when the backend hasn't
875
+ // shipped Phase 1 yet (useGroupedRender === false).
554
876
  const requestDetails: {
555
877
  purposes: UpdatePurpose[];
556
878
  correction_data: UpdateCorrectionDataItem[];
@@ -639,29 +961,37 @@ export default function Form({
639
961
  authContext,
640
962
  consentClient
641
963
  );
642
- // privacyCenterApiFetch unwraps { code, status, detail } responses and returns just the detail
643
- // So response will be { uuid, case_id } if successful (privacy center), or null if error
644
- // Case management returns the full CreateCaseResponse with { code, status, detail: { uuid, case_id } }
645
- let caseId: string | null = null;
646
- if (response) {
647
- if ('detail' in response && response.detail) {
648
- // Case management: wrapped response
649
- caseId =
650
- (response.detail as { case_id?: string; uuid?: string }).case_id ||
651
- (response.detail as { case_id?: string; uuid?: string }).uuid ||
652
- null;
653
- } else if ('case_id' in response || 'uuid' in response) {
654
- // Privacy center: unwrapped response
655
- caseId =
656
- (response as { case_id?: string; uuid?: string }).case_id ||
657
- (response as { case_id?: string; uuid?: string }).uuid ||
658
- null;
659
- }
660
- }
964
+ const extracted = extractCaseId(response);
965
+ const caseId = extracted?.case_id || extracted?.uuid || null;
661
966
 
662
967
  if (caseId) {
663
968
  queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
664
- setCaseId(caseId);
969
+ // P2-SDK-4 of TECH-3712. Wrap the lone response in a synthetic
970
+ // BatchCaseSubmitResult so RequestSubmit can render this path with
971
+ // the same UI it uses for the grouped batch path. When the grouped
972
+ // flow auto-selects a single product card we hydrate the synthetic
973
+ // entry with that product's identity so the success surface can
974
+ // render a product label alongside the case ID; otherwise we fall
975
+ // back to empty strings (legacy nomination/grievance/non-grouped
976
+ // DSAR have no product context to render).
977
+ const productInfo =
978
+ useGroupedRender && selectedGroups.length === 1
979
+ ? {
980
+ group_key: productGroupKey(selectedGroups[0]),
981
+ product_uuid: selectedGroups[0].product_uuid,
982
+ product_name: selectedGroups[0].product_name,
983
+ }
984
+ : { group_key: '', product_uuid: '', product_name: '' };
985
+ setBatchResult({
986
+ succeeded: [
987
+ {
988
+ ...productInfo,
989
+ case_id: caseId,
990
+ uuid: extracted?.uuid ?? caseId,
991
+ },
992
+ ],
993
+ failed: [],
994
+ });
665
995
  setIsSubmitted(true);
666
996
  toast.success(t('requestSubmittedSuccess'));
667
997
 
@@ -672,8 +1002,17 @@ export default function Form({
672
1002
  toast.error(t('requestSubmissionFailed'));
673
1003
  }
674
1004
  } catch (error) {
1005
+ // `createCaseRequest` returns null on non-auth errors (per its
1006
+ // documented contract) — those land in the `else` branch above
1007
+ // with a generic toast. This catch only fires on unexpected
1008
+ // exceptions further up the legacy code path; surface whatever
1009
+ // message the error carries.
675
1010
  console.error('Error submitting request:', error);
676
- toast.error(t('submissionFailed'));
1011
+ const message =
1012
+ error instanceof Error && error.message
1013
+ ? error.message
1014
+ : t('submissionFailed');
1015
+ toast.error(message);
677
1016
  } finally {
678
1017
  setIsLoading(false);
679
1018
  if (isPopup) {
@@ -682,10 +1021,315 @@ export default function Form({
682
1021
  }
683
1022
  };
684
1023
 
1024
+ /**
1025
+ * Phase 2 of TECH-3712. Single-product POST shared by `submitBatchCases`
1026
+ * and `handleRetry` (P2-SDK-4). Builds the per-group payload (anchors,
1027
+ * purposes, principal) and invokes `createCaseRequest`. The caller is
1028
+ * responsible for catching/aggregating errors — this helper either
1029
+ * resolves with the response or throws.
1030
+ *
1031
+ * Anchors per group:
1032
+ * - `product_uuid` from the group.
1033
+ * - `data_principal_uuid` is set ONLY for nominated cards (the
1034
+ * requestor is acting on someone else's behalf — the nominator's
1035
+ * ConsentUser uuid). For direct cards we omit the field entirely
1036
+ * so the backend defaults it from `requestor_uuid` (derived from
1037
+ * the JWT). `formData.uuid` here is the FormDataOut session uuid,
1038
+ * NOT the requestor's ConsentUser uuid, and would be rejected by
1039
+ * the backend's ConsentUser lookup.
1040
+ */
1041
+ const submitSingleProductCase = async (
1042
+ group: FormProductGroup,
1043
+ revokeConsent: boolean,
1044
+ ): Promise<CreateCaseResponse | null> => {
1045
+ const groupKey = productGroupKey(group);
1046
+
1047
+ // ACCESS/ERASURE per-group purposes. Empty for CORRECTION — that path
1048
+ // populates `correction_data` below instead.
1049
+ const groupPurposes = purposesByGroupKey.get(groupKey) ?? [];
1050
+ const purposesPayload: UpdatePurpose[] =
1051
+ requestType === RequestTypeEnum.CORRECTION
1052
+ ? []
1053
+ : groupPurposes
1054
+ .filter(
1055
+ (p) =>
1056
+ p.selected || p.data_elements.some((de) => de.selected),
1057
+ )
1058
+ .map((p) => ({
1059
+ purpose_uuid: p.uuid,
1060
+ data_element_uuids: p.data_elements
1061
+ .filter((de) => de.selected)
1062
+ .map((de) => de.uuid),
1063
+ }));
1064
+
1065
+ // TECH-3712 (correction grouped path). Per-group correction items: map
1066
+ // each selected element name to its {current, updated} pair. Names are
1067
+ // reverse-translated back to English via `getEnglishDataElementName`
1068
+ // so the backend always receives the canonical attribute name even
1069
+ // when the UI is showing a translated label.
1070
+ const groupSelectedNames =
1071
+ requestType === RequestTypeEnum.CORRECTION
1072
+ ? correctionElementsByGroupKey.get(groupKey) ?? []
1073
+ : [];
1074
+ const groupValues =
1075
+ requestType === RequestTypeEnum.CORRECTION
1076
+ ? correctionValuesByGroupKey.get(groupKey) ?? {}
1077
+ : {};
1078
+ const correctionPayload: UpdateCorrectionDataItem[] =
1079
+ requestType === RequestTypeEnum.CORRECTION
1080
+ ? groupSelectedNames.map((name) => ({
1081
+ name: getEnglishDataElementName(name),
1082
+ curr_value: groupValues[name]?.current ?? '',
1083
+ new_value: groupValues[name]?.updated ?? '',
1084
+ }))
1085
+ : [];
1086
+
1087
+ const payload: UserDataRequest = {
1088
+ name: ' ',
1089
+ contact: formData.contact,
1090
+ request_type: requestType?.toLowerCase() as RequestTypeEnum,
1091
+ uuid: formData.uuid,
1092
+ supporting_docs_uuids: [...uploadedDocumentId],
1093
+ request_details: {
1094
+ purposes: purposesPayload,
1095
+ correction_data: correctionPayload,
1096
+ grievance_types: [],
1097
+ },
1098
+ time_period:
1099
+ requestType === RequestTypeEnum.ACCESS
1100
+ ? parseInt(timePeriod || '30', 10)
1101
+ : 0,
1102
+ requestor_note: formData.additionalNote || '',
1103
+ ...(requestType === RequestTypeEnum.ERASURE
1104
+ ? { revoke_consent_on_fulfilment: revokeConsent }
1105
+ : {}),
1106
+ product_uuid: group.product_uuid,
1107
+ // Only set data_principal_uuid for nominee-driven cases. Direct
1108
+ // cards default server-side to the JWT-derived requestor_uuid.
1109
+ ...(group.nominator
1110
+ ? { data_principal_uuid: group.nominator.uuid }
1111
+ : {}),
1112
+ };
1113
+
1114
+ // Use the "unsafe" variant in the batch path so non-auth errors throw
1115
+ // instead of being swallowed to null — `Promise.allSettled` upstream
1116
+ // catches them and surfaces the backend message (e.g. `[400]
1117
+ // invalid_data_principal`) in `failed[].error` for that row.
1118
+ return await createCaseRequestUnsafe(
1119
+ organizationId || '',
1120
+ workspaceId || '',
1121
+ payload,
1122
+ isPopup,
1123
+ isCaseManagementContext,
1124
+ authContext,
1125
+ consentClient,
1126
+ );
1127
+ };
1128
+
1129
+ /**
1130
+ * Phase 2 of TECH-3712. Fan one `createCaseRequestUnsafe` per selected
1131
+ * product group via `Promise.allSettled`, aggregate into
1132
+ * `BatchCaseSubmitResult`.
1133
+ */
1134
+ const submitBatchCases = async (
1135
+ revokeConsent: boolean,
1136
+ ): Promise<BatchCaseSubmitResult> => {
1137
+ const submissions = selectedGroups.map((group) =>
1138
+ submitSingleProductCase(group, revokeConsent).then(
1139
+ (response) => ({ group, response }),
1140
+ ),
1141
+ );
1142
+
1143
+ const results = await Promise.allSettled(submissions);
1144
+ const batchResult: BatchCaseSubmitResult = { succeeded: [], failed: [] };
1145
+
1146
+ results.forEach((result, idx) => {
1147
+ const group = selectedGroups[idx];
1148
+ const groupKey = productGroupKey(group);
1149
+ if (result.status === 'fulfilled') {
1150
+ const extracted = extractCaseId(result.value.response);
1151
+ if (extracted && (extracted.case_id || extracted.uuid)) {
1152
+ batchResult.succeeded.push({
1153
+ group_key: groupKey,
1154
+ product_uuid: group.product_uuid,
1155
+ product_name: group.product_name,
1156
+ case_id: extracted.case_id || extracted.uuid,
1157
+ uuid: extracted.uuid || extracted.case_id,
1158
+ });
1159
+ } else {
1160
+ batchResult.failed.push({
1161
+ group_key: groupKey,
1162
+ product_uuid: group.product_uuid,
1163
+ product_name: group.product_name,
1164
+ error: t('requestSubmissionFailed'),
1165
+ });
1166
+ }
1167
+ } else {
1168
+ const reason = result.reason;
1169
+ const message =
1170
+ reason instanceof Error
1171
+ ? reason.message
1172
+ : typeof reason === 'string'
1173
+ ? reason
1174
+ : t('submissionFailed');
1175
+ batchResult.failed.push({
1176
+ group_key: groupKey,
1177
+ product_uuid: group.product_uuid,
1178
+ product_name: group.product_name,
1179
+ error: message,
1180
+ });
1181
+ }
1182
+ });
1183
+
1184
+ return batchResult;
1185
+ };
1186
+
1187
+ /**
1188
+ * P2-SDK-4 of TECH-3712. Re-issue a single POST for one product-group
1189
+ * that previously failed in the batch. On success, promote the entry
1190
+ * from `batchResult.failed` to `batchResult.succeeded` so the UI can
1191
+ * update in place. On failure, refresh the recorded error message so
1192
+ * the user can try again.
1193
+ *
1194
+ * Keyed by `productGroupKey(group)` — NOT `product_uuid` — so that a
1195
+ * product picked both directly AND via a nominator (two distinct
1196
+ * groups with the same `product_uuid`) retries the right entry. No-op
1197
+ * for the legacy single-case path (which has no group context to
1198
+ * retry against — failures there don't land on this surface; they
1199
+ * short-circuit before `setIsSubmitted`).
1200
+ */
1201
+ const handleRetry = useCallback(
1202
+ async (groupKey: string) => {
1203
+ if (groupKey === '') {
1204
+ return;
1205
+ }
1206
+ const group = selectedGroups.find(
1207
+ (g) => productGroupKey(g) === groupKey,
1208
+ );
1209
+ if (!group) {
1210
+ return;
1211
+ }
1212
+
1213
+ setRetryingGroupKeys((prev) => {
1214
+ const next = new Set(prev);
1215
+ next.add(groupKey);
1216
+ return next;
1217
+ });
1218
+
1219
+ try {
1220
+ const response = await submitSingleProductCase(group, false);
1221
+ const extracted = extractCaseId(response);
1222
+ if (extracted && (extracted.case_id || extracted.uuid)) {
1223
+ queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
1224
+ setBatchResult((prev) => {
1225
+ if (!prev) {
1226
+ return prev;
1227
+ }
1228
+ return {
1229
+ succeeded: [
1230
+ ...prev.succeeded,
1231
+ {
1232
+ group_key: groupKey,
1233
+ product_uuid: group.product_uuid,
1234
+ product_name: group.product_name,
1235
+ case_id: extracted.case_id || extracted.uuid,
1236
+ uuid: extracted.uuid || extracted.case_id,
1237
+ },
1238
+ ],
1239
+ failed: prev.failed.filter((f) => f.group_key !== groupKey),
1240
+ };
1241
+ });
1242
+ toast.success(t('requestSubmittedSuccess'));
1243
+ } else {
1244
+ setBatchResult((prev) => {
1245
+ if (!prev) {
1246
+ return prev;
1247
+ }
1248
+ return {
1249
+ succeeded: prev.succeeded,
1250
+ failed: prev.failed.map((f) =>
1251
+ f.group_key === groupKey
1252
+ ? { ...f, error: t('requestSubmissionFailed') }
1253
+ : f,
1254
+ ),
1255
+ };
1256
+ });
1257
+ }
1258
+ } catch (error) {
1259
+ const message =
1260
+ error instanceof Error ? error.message : t('submissionFailed');
1261
+ setBatchResult((prev) => {
1262
+ if (!prev) {
1263
+ return prev;
1264
+ }
1265
+ return {
1266
+ succeeded: prev.succeeded,
1267
+ failed: prev.failed.map((f) =>
1268
+ f.group_key === groupKey ? { ...f, error: message } : f,
1269
+ ),
1270
+ };
1271
+ });
1272
+ } finally {
1273
+ setRetryingGroupKeys((prev) => {
1274
+ const next = new Set(prev);
1275
+ next.delete(groupKey);
1276
+ return next;
1277
+ });
1278
+ }
1279
+ },
1280
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1281
+ [
1282
+ selectedGroups,
1283
+ queryClient,
1284
+ t,
1285
+ // submitSingleProductCase closes over many form-state values; we
1286
+ // intentionally don't list it to avoid identity churn re-rendering
1287
+ // RequestSubmit while a retry is in flight.
1288
+ ],
1289
+ );
1290
+
685
1291
  const isRequestDetailsValid = useCallback(() => {
686
- if (!requestType){
687
- return false
688
- };
1292
+ if (!requestType) {
1293
+ return false;
1294
+ }
1295
+
1296
+ // Phase 2 of TECH-3712. Grouped DSAR validity: at least one product
1297
+ // selected, and EVERY selected group has at least one purpose/data
1298
+ // element chosen. The batch path fires one POST per selected group
1299
+ // (Promise.allSettled), so a partially-filled card would either
1300
+ // produce an empty `purposes` / `correction_data` payload that the
1301
+ // backend rejects, or land as a no-op case. Require completeness
1302
+ // across all selected groups before allowing submit.
1303
+ if (useGroupedRender && isGroupedRequestType(requestType)) {
1304
+ if (selectedGroups.length === 0) {
1305
+ return false;
1306
+ }
1307
+ if (requestType === RequestTypeEnum.CORRECTION) {
1308
+ // For CORRECTION every selected group must have at least one
1309
+ // chosen data element AND every chosen element must carry both
1310
+ // current and updated values.
1311
+ return selectedGroups.every((group) => {
1312
+ const key = productGroupKey(group);
1313
+ const selected = correctionElementsByGroupKey.get(key) ?? [];
1314
+ const values = correctionValuesByGroupKey.get(key) ?? {};
1315
+ return (
1316
+ selected.length > 0 &&
1317
+ selected.every(
1318
+ (name) =>
1319
+ values[name]?.current?.trim() &&
1320
+ values[name]?.updated?.trim(),
1321
+ )
1322
+ );
1323
+ });
1324
+ }
1325
+ return selectedGroups.every((group) => {
1326
+ const purposes = purposesByGroupKey.get(productGroupKey(group)) ?? [];
1327
+ return purposes.some(
1328
+ (p) => p.selected || p.data_elements.some((de) => de.selected),
1329
+ );
1330
+ });
1331
+ }
1332
+
689
1333
  switch (requestType) {
690
1334
  case RequestTypeEnum.ACCESS:
691
1335
  return accessPurposes.some(
@@ -718,6 +1362,11 @@ export default function Form({
718
1362
  }
719
1363
  }, [
720
1364
  requestType,
1365
+ useGroupedRender,
1366
+ selectedGroups,
1367
+ purposesByGroupKey,
1368
+ correctionElementsByGroupKey,
1369
+ correctionValuesByGroupKey,
721
1370
  accessPurposes,
722
1371
  erasurePurposes,
723
1372
  selectedGrievances,
@@ -918,13 +1567,13 @@ export default function Form({
918
1567
 
919
1568
  return (
920
1569
  <>
921
- {isSubmitted && !isPopup ? (
1570
+ {isSubmitted && batchResult && !isPopup ? (
922
1571
  <RequestSubmit
923
- caseId={caseId || ''}
1572
+ result={batchResult}
924
1573
  contact={formData?.contact}
925
1574
  onCreateRequest={onCreateRequest}
926
- revokeConsentOnFulfilment={revokeConsentOnFulfilment}
927
- revokedPurposeNames={selectedErasurePurposeNames}
1575
+ onRetry={handleRetry}
1576
+ retryingGroupKeys={retryingGroupKeys}
928
1577
  />
929
1578
  ) : (
930
1579
  <div>
@@ -1128,7 +1777,115 @@ export default function Form({
1128
1777
  <div style={{
1129
1778
  padding: getVar('--pc-spacing-md'),
1130
1779
  }}>
1131
- {requestType === RequestTypeEnum.ACCESS ||
1780
+ {useGroupedRender && isGroupedRequestType(requestType) ? (
1781
+ <div
1782
+ style={{
1783
+ ...pcStyles.flex,
1784
+ ...pcStyles.flexCol,
1785
+ gap: getVar('--pc-spacing-md'),
1786
+ }}
1787
+ >
1788
+ {!skipProductStep && (
1789
+ <ProductSelectionSection
1790
+ directGroups={data?.direct_groups ?? []}
1791
+ nominatedGroups={data?.nominated_groups ?? []}
1792
+ selectedProductKey={selectedProductKey}
1793
+ onSelect={handleProductSelect}
1794
+ requestTypeLabel={requestTypeLabels[requestType]}
1795
+ isTranslating={isTranslating}
1796
+ />
1797
+ )}
1798
+ {selectedGroups.length > 0 && (() => {
1799
+ const group = selectedGroups[0];
1800
+ const key = productGroupKey(group);
1801
+ const groupPurposes = purposesByGroupKey.get(key) ?? [];
1802
+ const isCorrection =
1803
+ requestType === RequestTypeEnum.CORRECTION;
1804
+ const groupDataElements = isCorrection
1805
+ ? buildGroupDataElements(group)
1806
+ : [];
1807
+ const groupCorrectionSelected =
1808
+ correctionElementsByGroupKey.get(key) ?? [];
1809
+ const groupCorrectionValues =
1810
+ correctionValuesByGroupKey.get(key) ?? {};
1811
+ const nominatorLabel = group.nominator
1812
+ ? group.nominator.name ??
1813
+ group.nominator.email ??
1814
+ ''
1815
+ : '';
1816
+
1817
+ return (
1818
+ <div
1819
+ className="pc-product-purpose-panel"
1820
+ data-testid={`per-product-purposes-${key}`}
1821
+ >
1822
+ <div className="pc-product-purpose-panel-header">
1823
+ <div className="pc-product-purpose-panel-title-row">
1824
+ <PCText
1825
+ as="span"
1826
+ size="base"
1827
+ weight="semibold"
1828
+ color="primary"
1829
+ className="pc-product-purpose-panel-title"
1830
+ >
1831
+ {group.product_name}
1832
+ </PCText>
1833
+ {group.nominator && (
1834
+ <PCText
1835
+ as="span"
1836
+ size="xs"
1837
+ weight="medium"
1838
+ color="secondary"
1839
+ className="pc-product-purpose-panel-nominator"
1840
+ >
1841
+ {t('actingOnBehalfOfShort', {
1842
+ nominator: nominatorLabel,
1843
+ })}
1844
+ </PCText>
1845
+ )}
1846
+ </div>
1847
+ <PCText
1848
+ as="span"
1849
+ size="xs"
1850
+ color="secondary"
1851
+ className="pc-product-purpose-panel-subtitle"
1852
+ >
1853
+ {t('selectPurposesForThisProduct')}
1854
+ </PCText>
1855
+ </div>
1856
+ <div
1857
+ className="pc-product-purpose-panel-body"
1858
+ data-testid={`per-product-purposes-body-${key}`}
1859
+ >
1860
+ {isCorrection ? (
1861
+ <CorrectionDataSection
1862
+ dataElements={groupDataElements}
1863
+ selected={groupCorrectionSelected}
1864
+ setSelected={(values) =>
1865
+ setCorrectionElementsForGroup(key, values)
1866
+ }
1867
+ correctionValues={groupCorrectionValues}
1868
+ setCorrectionValues={(values) =>
1869
+ setCorrectionValuesForGroup(key, values)
1870
+ }
1871
+ isTranslating={isTranslating}
1872
+ />
1873
+ ) : (
1874
+ <PurposeSelectionSection
1875
+ purposes={groupPurposes}
1876
+ setPurposes={(updated) =>
1877
+ setPurposesForGroup(key, updated)
1878
+ }
1879
+ requestType={requestType}
1880
+ isTranslating={isTranslating}
1881
+ />
1882
+ )}
1883
+ </div>
1884
+ </div>
1885
+ );
1886
+ })()}
1887
+ </div>
1888
+ ) : requestType === RequestTypeEnum.ACCESS ||
1132
1889
  requestType === RequestTypeEnum.ERASURE ? (
1133
1890
  <PurposeSelectionSection
1134
1891
  purposes={
@@ -1511,82 +2268,6 @@ export default function Form({
1511
2268
  </div>
1512
2269
  </div>
1513
2270
  )}
1514
- <PCModal
1515
- isOpen={isErasureConsentModalOpen}
1516
- onClose={handleErasureModalClose}
1517
- size="md"
1518
- showCloseButton={false}
1519
- footer={
1520
- <div className="pc-erasure-modal-footer">
1521
- <PCButton
1522
- variant="secondary"
1523
- onClick={() => handleErasureModalChoice(false)}
1524
- disabled={isLoading}
1525
- >
1526
- {t('revokeConsentNo')}
1527
- </PCButton>
1528
- <PCButton
1529
- variant="primary"
1530
- onClick={() => handleErasureModalChoice(true)}
1531
- disabled={isLoading}
1532
- isLoading={isLoading}
1533
- >
1534
- {t('revokeConsentYes')}
1535
- </PCButton>
1536
- </div>
1537
- }
1538
- >
1539
- <div className="pc-erasure-modal-content">
1540
- <div className="pc-erasure-modal-header">
1541
- <div className="pc-erasure-modal-icon">
1542
- <FiAlertCircle size={24} />
1543
- </div>
1544
- <p className="pc-erasure-modal-title">
1545
- {t('revokeConsentModalTitle')}
1546
- </p>
1547
- <p className="pc-erasure-modal-question">
1548
- {t('revokeConsentModalQuestion')}
1549
- </p>
1550
- </div>
1551
-
1552
- <div className="pc-erasure-modal-purposes">
1553
- <p className="pc-erasure-modal-purposes-label">
1554
- {t('revokeConsentModalPurposesLabel')}
1555
- </p>
1556
- {selectedErasurePurposeList.length > 0 ? (
1557
- <>
1558
- {selectedErasurePurposeList
1559
- .slice(0, MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL)
1560
- .map((name) => (
1561
- <div key={name} className="pc-erasure-modal-purpose-item">
1562
- {name}
1563
- </div>
1564
- ))}
1565
- {selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL && (
1566
- <span className="pc-erasure-modal-purposes-placeholder">
1567
- {t('revokeConsentModalAndMore', {
1568
- count: selectedErasurePurposeList.length - MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL,
1569
- })}
1570
- </span>
1571
- )}
1572
- </>
1573
- ) : (
1574
- <span className="pc-erasure-modal-purposes-placeholder">
1575
- {t('revokeConsentModalYourPurposes')}
1576
- </span>
1577
- )}
1578
- </div>
1579
-
1580
- <div className="pc-erasure-modal-warning">
1581
- <p className="pc-erasure-modal-warning-text">
1582
- <span className="pc-erasure-modal-warning-label">
1583
- {t('revokeConsentModalIfYesLabel')}
1584
- </span>{' '}
1585
- {t('revokeConsentModalIfYesBody')}
1586
- </p>
1587
- </div>
1588
- </div>
1589
- </PCModal>
1590
2271
  </>
1591
2272
  );
1592
2273
  }