@redacto.io/consent-sdk-react 10.0.0-beta.9 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/NOTICE.md +17 -0
  2. package/README.md +141 -3
  3. package/dist/chunk-FEGTW73Q.mjs +182 -0
  4. package/dist/fonts.d.mts +35 -0
  5. package/dist/fonts.d.ts +35 -0
  6. package/dist/fonts.js +183 -0
  7. package/dist/fonts.mjs +10 -0
  8. package/dist/index.d.mts +12 -1
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.js +724 -365
  11. package/dist/index.mjs +346 -201
  12. package/dist/montserrat.generated-NBTECYRW.mjs +22 -0
  13. package/dist/privacy-center.d.mts +0 -1
  14. package/dist/privacy-center.d.ts +0 -1
  15. package/dist/privacy-center.js +2 -66
  16. package/dist/privacy-center.mjs +2 -66
  17. package/dist/types-6Aj7faxL.d.mts +57 -0
  18. package/dist/types-6Aj7faxL.d.ts +57 -0
  19. package/package.json +10 -1
  20. package/.turbo/turbo-build.log +0 -22
  21. package/.turbo/turbo-test.log +0 -203
  22. package/.turbo/turbo-verify-pack.log +0 -6
  23. package/CHANGELOG.md +0 -353
  24. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +0 -934
  25. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +0 -2160
  26. package/src/RedactoNoticeAssisted/api/index.ts +0 -275
  27. package/src/RedactoNoticeAssisted/api/types.ts +0 -182
  28. package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
  29. package/src/RedactoNoticeAssisted/constants.ts +0 -51
  30. package/src/RedactoNoticeAssisted/i18n.ts +0 -1408
  31. package/src/RedactoNoticeAssisted/index.ts +0 -1
  32. package/src/RedactoNoticeAssisted/injectStyles.ts +0 -251
  33. package/src/RedactoNoticeAssisted/styles.ts +0 -730
  34. package/src/RedactoNoticeAssisted/types.ts +0 -26
  35. package/src/RedactoNoticeAssisted/useMediaQuery.ts +0 -36
  36. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +0 -1152
  37. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +0 -4308
  38. package/src/RedactoNoticeConsent/api/index.ts +0 -542
  39. package/src/RedactoNoticeConsent/api/sandbox.ts +0 -150
  40. package/src/RedactoNoticeConsent/api/types.ts +0 -515
  41. package/src/RedactoNoticeConsent/assets/redacto-logo.png +0 -0
  42. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +0 -602
  43. package/src/RedactoNoticeConsent/components/index.ts +0 -7
  44. package/src/RedactoNoticeConsent/constants.ts +0 -20
  45. package/src/RedactoNoticeConsent/index.ts +0 -1
  46. package/src/RedactoNoticeConsent/injectStyles.ts +0 -151
  47. package/src/RedactoNoticeConsent/language-codes.ts +0 -95
  48. package/src/RedactoNoticeConsent/services/storage.ts +0 -122
  49. package/src/RedactoNoticeConsent/styles.ts +0 -409
  50. package/src/RedactoNoticeConsent/types.ts +0 -80
  51. package/src/RedactoNoticeConsent/useMediaQuery.ts +0 -36
  52. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.test.tsx +0 -369
  53. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -687
  54. package/src/RedactoNoticeConsentInline/api/index.ts +0 -106
  55. package/src/RedactoNoticeConsentInline/api/types.ts +0 -183
  56. package/src/RedactoNoticeConsentInline/assets/redacto-logo.png +0 -0
  57. package/src/RedactoNoticeConsentInline/index.ts +0 -1
  58. package/src/RedactoNoticeConsentInline/injectStyles.ts +0 -40
  59. package/src/RedactoNoticeConsentInline/styles.ts +0 -397
  60. package/src/RedactoNoticeConsentInline/types.ts +0 -51
  61. package/src/RedactoNoticeConsentInline/useMediaQuery.ts +0 -36
  62. package/src/RedactoPrivacyCenter/NotificationsPage.tsx +0 -182
  63. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +0 -19
  64. package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +0 -156
  65. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +0 -17
  66. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +0 -79
  67. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +0 -36
  68. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +0 -177
  69. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +0 -26
  70. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +0 -93
  71. package/src/RedactoPrivacyCenter/api/actions.ts +0 -695
  72. package/src/RedactoPrivacyCenter/api/client.ts +0 -1003
  73. package/src/RedactoPrivacyCenter/api/config.ts +0 -33
  74. package/src/RedactoPrivacyCenter/api/fetcher.ts +0 -275
  75. package/src/RedactoPrivacyCenter/api/orgBranding.ts +0 -16
  76. package/src/RedactoPrivacyCenter/api/upload.ts +0 -93
  77. package/src/RedactoPrivacyCenter/assets/character.svg +0 -31
  78. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +0 -9
  79. package/src/RedactoPrivacyCenter/assets/greenTick.svg +0 -4
  80. package/src/RedactoPrivacyCenter/assets/mail.svg +0 -3
  81. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +0 -9
  82. package/src/RedactoPrivacyCenter/assets/sentImg.svg +0 -9
  83. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +0 -9
  84. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +0 -252
  85. package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +0 -239
  86. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +0 -268
  87. package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +0 -218
  88. package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +0 -205
  89. package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +0 -107
  90. package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +0 -100
  91. package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +0 -339
  92. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +0 -245
  93. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +0 -28
  94. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +0 -40
  95. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +0 -62
  96. package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +0 -36
  97. package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +0 -38
  98. package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +0 -31
  99. package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +0 -79
  100. package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +0 -25
  101. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +0 -96
  102. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +0 -97
  103. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +0 -156
  104. package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +0 -171
  105. package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +0 -23
  106. package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +0 -116
  107. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +0 -43
  108. package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +0 -71
  109. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +0 -469
  110. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +0 -426
  111. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +0 -1206
  112. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +0 -218
  113. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +0 -138
  114. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +0 -7
  115. package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +0 -179
  116. package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +0 -318
  117. package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +0 -333
  118. package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +0 -102
  119. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +0 -159
  120. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +0 -189
  121. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +0 -118
  122. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +0 -66
  123. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +0 -222
  124. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +0 -331
  125. package/src/RedactoPrivacyCenter/components/Form.tsx +0 -1821
  126. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +0 -174
  127. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +0 -337
  128. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +0 -334
  129. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +0 -173
  130. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +0 -18
  131. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +0 -54
  132. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +0 -57
  133. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +0 -19
  134. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +0 -92
  135. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +0 -20
  136. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +0 -58
  137. package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +0 -10
  138. package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +0 -112
  139. package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +0 -27
  140. package/src/RedactoPrivacyCenter/components/Pagination.tsx +0 -140
  141. package/src/RedactoPrivacyCenter/components/PrivacyCenterEmptyState.tsx +0 -76
  142. package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +0 -97
  143. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +0 -454
  144. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +0 -270
  145. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +0 -95
  146. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +0 -195
  147. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +0 -25
  148. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -21
  149. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +0 -159
  150. package/src/RedactoPrivacyCenter/components/constant.ts +0 -52
  151. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +0 -155
  152. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +0 -203
  153. package/src/RedactoPrivacyCenter/components/types.ts +0 -123
  154. package/src/RedactoPrivacyCenter/components/utils.tsx +0 -10
  155. package/src/RedactoPrivacyCenter/config/routes.ts +0 -9
  156. package/src/RedactoPrivacyCenter/config/table.ts +0 -25
  157. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +0 -252
  158. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +0 -47
  159. package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +0 -52
  160. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +0 -92
  161. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +0 -181
  162. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +0 -11
  163. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +0 -96
  164. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +0 -30
  165. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +0 -80
  166. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +0 -15
  167. package/src/RedactoPrivacyCenter/context/types.ts +0 -57
  168. package/src/RedactoPrivacyCenter/lib/api.ts +0 -759
  169. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +0 -67
  170. package/src/RedactoPrivacyCenter/lib/constants.ts +0 -283
  171. package/src/RedactoPrivacyCenter/lib/i18n.ts +0 -81
  172. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -102
  173. package/src/RedactoPrivacyCenter/lib/queries.ts +0 -141
  174. package/src/RedactoPrivacyCenter/lib/sandbox.ts +0 -104
  175. package/src/RedactoPrivacyCenter/lib/types.ts +0 -896
  176. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +0 -37
  177. package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +0 -127
  178. package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +0 -85
  179. package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +0 -109
  180. package/src/RedactoPrivacyCenter/lib/utils.ts +0 -798
  181. package/src/RedactoPrivacyCenter/locales/as/translation.json +0 -342
  182. package/src/RedactoPrivacyCenter/locales/bn/translation.json +0 -342
  183. package/src/RedactoPrivacyCenter/locales/brx/translation.json +0 -342
  184. package/src/RedactoPrivacyCenter/locales/doi/translation.json +0 -342
  185. package/src/RedactoPrivacyCenter/locales/en/translation.json +0 -357
  186. package/src/RedactoPrivacyCenter/locales/gom/translation.json +0 -342
  187. package/src/RedactoPrivacyCenter/locales/gu/translation.json +0 -342
  188. package/src/RedactoPrivacyCenter/locales/hi/translation.json +0 -345
  189. package/src/RedactoPrivacyCenter/locales/kn/translation.json +0 -342
  190. package/src/RedactoPrivacyCenter/locales/ks/translation.json +0 -342
  191. package/src/RedactoPrivacyCenter/locales/mai/translation.json +0 -342
  192. package/src/RedactoPrivacyCenter/locales/ml/translation.json +0 -342
  193. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +0 -342
  194. package/src/RedactoPrivacyCenter/locales/mr/translation.json +0 -342
  195. package/src/RedactoPrivacyCenter/locales/ne/translation.json +0 -342
  196. package/src/RedactoPrivacyCenter/locales/or/translation.json +0 -342
  197. package/src/RedactoPrivacyCenter/locales/pa/translation.json +0 -342
  198. package/src/RedactoPrivacyCenter/locales/sa/translation.json +0 -342
  199. package/src/RedactoPrivacyCenter/locales/sat/translation.json +0 -342
  200. package/src/RedactoPrivacyCenter/locales/sd/translation.json +0 -342
  201. package/src/RedactoPrivacyCenter/locales/ta/translation.json +0 -342
  202. package/src/RedactoPrivacyCenter/locales/te/translation.json +0 -342
  203. package/src/RedactoPrivacyCenter/locales/ur/translation.json +0 -342
  204. package/src/RedactoPrivacyCenter/styles/applyBrandingTheme.ts +0 -66
  205. package/src/RedactoPrivacyCenter/styles/base.css +0 -117
  206. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +0 -3108
  207. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +0 -831
  208. package/src/RedactoPrivacyCenter/styles/responsive.ts +0 -138
  209. package/src/RedactoPrivacyCenter/styles/variables.css +0 -96
  210. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +0 -387
  211. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +0 -468
  212. package/src/RedactoPrivacyCenter/table/index.ts +0 -2
  213. package/src/RedactoPrivacyCenter/types/images.d.ts +0 -29
  214. package/src/RedactoPrivacyCenter/ui/Footer.tsx +0 -69
  215. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +0 -54
  216. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +0 -93
  217. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +0 -47
  218. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +0 -83
  219. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +0 -93
  220. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +0 -48
  221. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +0 -77
  222. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +0 -55
  223. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +0 -107
  224. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +0 -35
  225. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +0 -132
  226. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +0 -31
  227. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +0 -106
  228. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +0 -206
  229. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +0 -433
  230. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +0 -149
  231. package/src/RedactoPrivacyCenter/ui/PCText.tsx +0 -41
  232. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +0 -43
  233. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +0 -37
  234. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +0 -121
  235. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +0 -25
  236. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +0 -53
  237. package/src/RedactoPrivacyCenter/ui/index.ts +0 -18
  238. package/src/RedactoPrivacyCenter/utils/cn.ts +0 -28
  239. package/src/RedactoPrivacyCenter/utils/date.ts +0 -48
  240. package/src/RedactoPrivacyCenter/utils/status.ts +0 -111
  241. package/src/RedactoPrivacyCenter/utils/toast.ts +0 -130
  242. package/src/index.ts +0 -19
  243. package/src/privacy-center.ts +0 -113
  244. package/src/shared/api-errors.ts +0 -180
  245. package/src/shared/notice-consent-status.ts +0 -190
  246. package/src/shared/sandbox.ts +0 -6
  247. package/src/types/css.d.ts +0 -4
  248. package/src/types/images.d.ts +0 -19
  249. package/src/types/react-jsx.d.ts +0 -26
  250. package/tests/DataAvailabilityContext.test.tsx +0 -123
  251. package/tests/Form.test.tsx +0 -1424
  252. package/tests/ManageConsentErrorMapping.test.tsx +0 -85
  253. package/tests/ProductSelectionSection.test.tsx +0 -328
  254. package/tests/RequestSubmit.test.tsx +0 -135
  255. package/tests/RevocablePurpose.test.tsx +0 -146
  256. package/tests/RevokeWarningMessage.test.tsx +0 -151
  257. package/tests/consentClient.test.ts +0 -217
  258. package/tests/manageConsentServer.test.ts +0 -38
  259. package/tests/mocks.ts +0 -257
  260. package/tests/resolveContactFromJwt.test.ts +0 -44
  261. package/tests/setup.ts +0 -16
  262. package/tsconfig.json +0 -16
  263. package/tsup.config.ts +0 -31
  264. package/vitest.config.ts +0 -16
@@ -1,1821 +0,0 @@
1
- import fileAttachIcon from '../assets/fileAttach.svg';
2
- import uploadFileIcon from '../assets/uploadFileIcon.svg';
3
- import { uploadFileWithS2S } from '../api/upload';
4
- import { FormBlockWrapper } from '../ui/FormBlockWrapper';
5
- import { useQueryClient } from '@tanstack/react-query';
6
- import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
7
- import { useTranslation } from 'react-i18next';
8
- import { AiOutlineClose } from 'react-icons/ai';
9
- import { PiUploadBold } from 'react-icons/pi';
10
- import { toast } from '../utils/toast';
11
- import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
12
- import { PCModal } from '../ui/PCModal';
13
- import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup, legacyFlatGroup } from './groupHelpers';
14
- import { pcStyles } from '../styles/pcStyles';
15
- import { createCaseRequest, fetchUserConsents, getFormData } from '../lib/api';
16
- import { FORM_PICKER_GROUP_LIMIT, LEGACY_PRODUCT_GROUP_UUID } from '../lib/constants';
17
- import { ensureValidToken } from '../lib/utils';
18
- import { useAuth } from '../context/AuthContext';
19
- import { useBaseUrl } from '../context/BaseUrlContext';
20
- import {
21
- useRequestTypeLabels,
22
- useTimePeriodOptions,
23
- } from '../lib/constants';
24
- import { useOrganization } from '../context/OrganizationContext';
25
- import i18n from '../lib/i18n';
26
- import { getLangParam } from '../lib/useCurrentLanguage';
27
- import type {
28
- CreateCaseResponse,
29
- FormData,
30
- FormProductGroup,
31
- GrievanceOptions,
32
- GrievanceTypeElement,
33
- GrievanceValue,
34
- NominationData,
35
- Purpose,
36
- UpdateCorrectionDataItem,
37
- UpdatePurpose,
38
- UserDataRequest,
39
- } from '../lib/types';
40
- import { RequestTypeEnum } from '../lib/types';
41
- import CorrectionDataSection from './DataRequest/CorrectionDataSection';
42
- import GrievanceDataSection from './DataRequest/GrievanceDataSection';
43
- import NominationDataSection from './DataRequest/NominationDataSection';
44
- import {
45
- ProductSelectionSection,
46
- productGroupKey,
47
- } from './DataRequest/ProductSelectionSection';
48
- import PurposeSelectionSection from './DataRequest/PurposeSelectionSection';
49
- import RequestSubmit from './RequestSubmit';
50
- import type { CaseHistoryProps } from './types';
51
- import { FiAlertCircle, FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
52
- import { MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL } from './constant';
53
-
54
- const getVar = (varName: string): string => `var(${varName})`;
55
-
56
- /**
57
- * Narrow shape of the `case_id`/`uuid` fields extracted from a
58
- * `CreateCaseResponse` (or its unwrapped privacy-center variant). Both
59
- * are stringly returned — empty strings mean "not present on the
60
- * response" rather than `null`, which keeps the call sites simple.
61
- */
62
- type ExtractedCaseRef = { uuid: string; case_id: string };
63
-
64
- /**
65
- * The two response shapes `extractCaseId` walks before resolving an
66
- * `ExtractedCaseRef`. Privacy-center returns the unwrapped detail;
67
- * case-management wraps it in `{ code, status, detail }`.
68
- */
69
- type CaseIdContainer = { case_id?: string; uuid?: string };
70
-
71
- /**
72
- * Request types that engage the per-product picker and require a
73
- * product anchor (`product_uuid`) on the submitted case. Nomination
74
- * and grievance are org-level and skip the product step entirely.
75
- */
76
- const PRODUCT_SCOPED_REQUEST_TYPES: ReadonlySet<RequestTypeEnum> = new Set([
77
- RequestTypeEnum.ACCESS,
78
- RequestTypeEnum.ERASURE,
79
- RequestTypeEnum.CORRECTION,
80
- ]);
81
-
82
- const isProductScopedRequestType = (
83
- rt: RequestTypeEnum | null,
84
- ): rt is RequestTypeEnum =>
85
- rt !== null && PRODUCT_SCOPED_REQUEST_TYPES.has(rt);
86
-
87
- /**
88
- * Result shape rendered by RequestSubmit after a successful submission.
89
- * Product fields are populated only for product-scoped request types
90
- * (ACCESS / ERASURE / CORRECTION); nomination and grievance leave them
91
- * as empty strings so the success card omits the product label.
92
- */
93
- type SubmittedCase = {
94
- case_id: string;
95
- uuid: string;
96
- product_name: string;
97
- product_uuid: string;
98
- };
99
-
100
- /**
101
- * Pull the `case_id` out of a `createCaseRequest` response, handling both
102
- * the privacy-center shape (unwrapped detail) and the case-management shape
103
- * (wrapped { code, status, detail }).
104
- */
105
- const extractCaseId = (
106
- response: CreateCaseResponse | null,
107
- ): ExtractedCaseRef | null => {
108
- if (!response) {
109
- return null;
110
- }
111
- if ('detail' in response && response.detail) {
112
- const detail = response.detail as CaseIdContainer;
113
- if (detail.case_id || detail.uuid) {
114
- return { case_id: detail.case_id ?? '', uuid: detail.uuid ?? '' };
115
- }
116
- }
117
- const direct = response as unknown as CaseIdContainer;
118
- if (direct.case_id || direct.uuid) {
119
- return { case_id: direct.case_id ?? '', uuid: direct.uuid ?? '' };
120
- }
121
- return null;
122
- };
123
-
124
- export default function Form({
125
- onCreateRequest,
126
- isPopup = false,
127
- onClose,
128
- }: CaseHistoryProps) {
129
- const queryClient = useQueryClient();
130
- const timePeriodOptions = useTimePeriodOptions();
131
- const { organization } = useOrganization();
132
- const orgSlug = organization?.slug || organization?.name || '';
133
-
134
- // Detect if we're in case management context (admin side)
135
- // Case management uses session auth, not JWT Bearer
136
- // We rely on an explicit pathname check rather than absence of access_token
137
- // to avoid incorrectly flagging unauthenticated privacy-center users as case-management
138
- const isCaseManagementContext =
139
- typeof window !== 'undefined' &&
140
- window.location.pathname.includes('/case-management');
141
- const [requestType, setRequestType] = useState<RequestTypeEnum | null>(null);
142
- const [timePeriod, setTimePeriod] = useState('');
143
- const [isSubmitted, setIsSubmitted] = useState(false);
144
- const { t } = useTranslation();
145
- const { accessToken, refreshToken, onError, updateTokens } = useAuth();
146
- const { consentClient, baseUrl } = useBaseUrl();
147
- const organizationId = organization?.uuid;
148
- const workspaceId = organization?.workspaces[0]?.uuid;
149
-
150
- // Create auth context object for API calls - memoized to prevent infinite loops
151
- const authContext = useMemo(() => ({
152
- accessToken,
153
- refreshToken,
154
- onError,
155
- updateTokens,
156
- }), [accessToken, refreshToken, onError, updateTokens]);
157
- const [data, setData] = useState<FormData | null>(null);
158
- const [formData, setFormData] = useState({
159
- uuid: '',
160
- case_id: '',
161
- name: '',
162
- contact: '',
163
- requestType: '',
164
- requestDetails: '',
165
- supportingDocumentation: '',
166
- additionalNote: '',
167
- confirmCheckBox: false,
168
- });
169
- // Selected product-group key on the product-cards step. Keyed by
170
- // `productGroupKey(group)` so the same product reached both directly
171
- // AND via a nominator stays addressable as two distinct selections.
172
- // null when no product is selected (or for org-level request types).
173
- const [selectedProductKey, setSelectedProductKey] = useState<string | null>(
174
- null,
175
- );
176
- // Purpose state for the currently-selected product group. Seeded from
177
- // `buildGroupPurposes(group)` whenever `selectedProductKey` changes, then
178
- // mutated in place by `PurposeSelectionSection` as the user picks
179
- // purposes / data elements. Single-select means we never need a Map.
180
- const [selectedPurposes, setSelectedPurposes] = useState<Purpose[]>([]);
181
- // CORRECTION selections for the currently-selected product group. These
182
- // are flat (not keyed by product) because the picker is single-select —
183
- // switching products re-seeds them via the same effect that re-seeds
184
- // `selectedPurposes`. Names are the data-element names visible in the
185
- // UI; they're reverse-translated to English at submit time.
186
- const [selectedCorrectionElements, setSelectedCorrectionElements] = useState<
187
- string[]
188
- >([]);
189
- const [correctionValues, setCorrectionValues] = useState<
190
- Record<string, { current: string; updated: string }>
191
- >({});
192
- // Result rendered by `RequestSubmit` after a successful submission.
193
- // Replaces the prior batch-shape now that submit is 1-form / 1-case.
194
- // Product fields are empty strings for nomination / grievance (no
195
- // product anchor) so the success card omits the product label.
196
- const [submittedCase, setSubmittedCase] = useState<SubmittedCase | null>(
197
- null,
198
- );
199
- const fileInputRef = useRef<HTMLInputElement>(null);
200
- // Store the original English form data for submission — translations are display-only
201
- const englishDataRef = useRef<FormData | null>(null);
202
- const [uploadedDocumentId, setUploadedDocumentId] = useState<string[]>([]);
203
- const [uploadedDocuments, setUploadedDocuments] = useState<
204
- { uuid: string; name: string }[]
205
- >([]);
206
- const [isDocumentUploading, setIsDocumentUploading] = useState(false);
207
- const [isDocument, setIsDocument] = useState(false);
208
- const [isPopupFormEnabled, setIsPopupFormEnabled] = useState(!isPopup);
209
- const [isVerifyingContact, setIsVerifyingContact] = useState(false);
210
- const requestTypeLabels = useRequestTypeLabels();
211
- const [grievanceOptions, setGrievanceOptions] = useState<GrievanceOptions[]>(
212
- []
213
- );
214
- const [selectedGrievances, setSelectedGrievances] = useState<
215
- Record<string, boolean>
216
- >({});
217
- const [nominationData, setNominationData] = useState<NominationData>({
218
- nominee_email: '',
219
- nominee_mobile: '',
220
- });
221
- const [isLoading, setIsLoading] = useState(false);
222
- const [isTranslating, setIsTranslating] = useState(false);
223
- const [isRequestDetailsExpanded, setIsRequestDetailsExpanded] =
224
- useState(true);
225
- const [isErasureConsentModalOpen, setIsErasureConsentModalOpen] =
226
- useState(false);
227
- const [revokeConsentOnFulfilment, setRevokeConsentOnFulfilment] =
228
- useState(false);
229
-
230
- const resetFileInput = () => {
231
- if (fileInputRef.current) {
232
- fileInputRef.current.value = '';
233
- }
234
- };
235
-
236
- // Union of direct + nominated product groups exposed by the backend.
237
- // Drives both the product-cards step and the per-product picker beneath.
238
- // When the backend exposes no groups at all but did return the legacy flat
239
- // `purposes` list (pre-grouping Python deployments, and the popup /
240
- // case-management contexts that cannot read user-consents), fall back to a
241
- // single synthetic group so the purposes stay reachable instead of the
242
- // picker claiming the principal has no consents.
243
- const allGroups = useMemo<FormProductGroup[]>(() => {
244
- if (!data) {
245
- return [];
246
- }
247
- const groups = [
248
- ...(data.direct_groups ?? []),
249
- ...(data.nominated_groups ?? []),
250
- ];
251
- if (groups.length > 0) {
252
- return groups;
253
- }
254
- const flatGroup = legacyFlatGroup(data, t('yourData'));
255
- // `buildGroupPurposes` is what the panel renders from — it drops purposes
256
- // with no data elements, so a flat list that survives nothing must keep
257
- // showing the empty state rather than an unusable panel.
258
- if (buildGroupPurposes(flatGroup).length === 0) {
259
- return [];
260
- }
261
- return [flatGroup];
262
- }, [data, t]);
263
-
264
- // TECH-3712 UX: when the DP has exactly one consent group total
265
- // (direct + nominated combined), the product-selection step is friction —
266
- // there's nothing to choose. Skip rendering ProductSelectionSection
267
- // entirely and auto-select the lone group below so the per-product
268
- // picker renders without an intermediate card. With zero groups we
269
- // still render the section so the empty-state message is visible.
270
- const skipProductStep = allGroups.length === 1;
271
-
272
- // The single product-group the user is currently working with. Null
273
- // when no product is selected (the picker is shown empty / unselected)
274
- // or when the request type is org-level (nomination / grievance).
275
- const selectedGroup = useMemo<FormProductGroup | null>(() => {
276
- if (selectedProductKey) {
277
- return (
278
- allGroups.find((g) => productGroupKey(g) === selectedProductKey) ??
279
- null
280
- );
281
- }
282
- if (skipProductStep) {
283
- return allGroups[0] ?? null;
284
- }
285
- return null;
286
- }, [allGroups, selectedProductKey, skipProductStep]);
287
-
288
- // Stable select handler for ProductSelectionSection. Single-group
289
- // auto-select fires this on mount via the child's effect.
290
- const handleProductSelect = useCallback(
291
- (key: string | null, _group: FormProductGroup | null) => {
292
- setSelectedProductKey(key);
293
- },
294
- [],
295
- );
296
-
297
- // Approach A: source the DSAR request-form product picker from user-consents
298
- // rather than from Python's form/data, which stopped emitting product groups
299
- // after the consent cutover. `consentClient.getUserConsents` serves the same
300
- // grouped payload from either backend — the Go ledger's
301
- // `/public/.../user-consents` when a ledgerBaseUrl is configured, Python's
302
- // `/dsar/privacy-center/user-consents` otherwise (the same source the
303
- // Manage Consent view reads in both modes) — so the overlay runs regardless
304
- // of which backend the SDK points at. Only the end-user privacy-center path
305
- // is eligible: popup / case-management resolve an arbitrary contact, which
306
- // the JWT-scoped user-consents endpoint cannot serve, so those keep the
307
- // form/data groups. Returns the form data unchanged when ineligible, when
308
- // the read fails, or when it comes back with no groups at all — an empty
309
- // result must never overwrite groups form/data did supply.
310
- const withUserConsentPickerGroups = useCallback(
311
- async (form: FormData, language?: string): Promise<FormData> => {
312
- if (isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
313
- return form;
314
- }
315
- const consents = await fetchUserConsents({
316
- organizationId,
317
- workspaceId,
318
- authContext,
319
- consentClient,
320
- // No status filter: include every status (active / expired / withdrawn
321
- // / declined), matching Python form/data, so a DSAR can target products
322
- // the user no longer actively consents to.
323
- limit: FORM_PICKER_GROUP_LIMIT,
324
- nominatedLimit: FORM_PICKER_GROUP_LIMIT,
325
- language,
326
- });
327
- const direct = consents?.direct ?? [];
328
- const nominated = consents?.nominated ?? [];
329
- if (direct.length === 0 && nominated.length === 0) {
330
- return form;
331
- }
332
- return {
333
- ...form,
334
- direct_groups: direct.map(consentGroupToFormProductGroup),
335
- nominated_groups: nominated.map(consentGroupToFormProductGroup),
336
- };
337
- },
338
- [
339
- isPopup,
340
- isCaseManagementContext,
341
- organizationId,
342
- workspaceId,
343
- authContext,
344
- consentClient,
345
- ],
346
- );
347
-
348
- const fetchAndApplyTranslations = useCallback(async (langValue: string) => {
349
- if (!organizationId || !workspaceId || !data) {
350
- return;
351
- }
352
-
353
- const contact = formData.contact ||
354
- (typeof window !== 'undefined' ? sessionStorage.getItem('email') || '' : '');
355
-
356
- if (!contact) {
357
- return;
358
- }
359
-
360
- setIsTranslating(true);
361
-
362
- try {
363
- const translatedFormData = await getFormData(
364
- organizationId,
365
- workspaceId,
366
- contact,
367
- isPopup,
368
- isCaseManagementContext,
369
- authContext,
370
- consentClient,
371
- langValue
372
- );
373
-
374
- if (!translatedFormData) {
375
- // Fallback to English when translation API unavailable
376
- toast.info(t('translationNotAvailable'));
377
- return;
378
- }
379
-
380
- if (!translatedFormData.purposes ||
381
- !Array.isArray(translatedFormData.purposes) ||
382
- translatedFormData.purposes.length === 0) {
383
- // Fallback to English when no translations for this language
384
- toast.info(t('translationNotAvailable'));
385
- return;
386
- }
387
-
388
- setData(await withUserConsentPickerGroups(translatedFormData, langValue));
389
-
390
- if (translatedFormData.grievance_options) {
391
- const currentGrievanceSelections = { ...selectedGrievances };
392
- setGrievanceOptions(translatedFormData.grievance_options);
393
- const updatedGrievanceSelections: Record<string, boolean> = {};
394
- translatedFormData.grievance_options.forEach((option) => {
395
- updatedGrievanceSelections[option.value] =
396
- currentGrievanceSelections[option.value] || false;
397
- });
398
- setSelectedGrievances(updatedGrievanceSelections);
399
- }
400
- } catch (error) {
401
- console.error('Error fetching translation:', error);
402
- // Fallback to English when translation API fails
403
- toast.info(t('translationNotAvailable'));
404
- } finally {
405
- setIsTranslating(false);
406
- }
407
- }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withUserConsentPickerGroups]);
408
-
409
- // Clear product / org-level selections when request type changes.
410
- // Per-product state (selectedPurposes, selectedCorrectionElements,
411
- // correctionValues) is owned by the seed effect below — clearing it
412
- // here too would race with that effect's re-seed when skipProductStep
413
- // is true (the sole group stays selected across request-type changes
414
- // because the auto-select effect refires on requestType).
415
- useEffect(() => {
416
- if (!skipProductStep) {
417
- setSelectedProductKey(null);
418
- }
419
- setNominationData({ nominee_email: '', nominee_mobile: '' });
420
-
421
- if (
422
- requestType === RequestTypeEnum.GRIEVANCE &&
423
- grievanceOptions.length > 0
424
- ) {
425
- const resetGrievances = grievanceOptions.reduce(
426
- (acc, option) => {
427
- acc[option.value] = false;
428
- return acc;
429
- },
430
- {} as Record<string, boolean>,
431
- );
432
- setSelectedGrievances(resetGrievances);
433
- }
434
- }, [requestType, grievanceOptions, skipProductStep]);
435
-
436
- // TECH-3712 UX: when `skipProductStep` is true (DP has exactly one
437
- // consent group total), auto-select that group so the per-product
438
- // picker renders directly without the user clicking a card. Placed
439
- // AFTER the request-type-change clearing effect above so a request-type
440
- // switch re-establishes the selection on the next tick. The child
441
- // ProductSelectionSection has an equivalent mount-time effect, but
442
- // that effect never fires here because the child is not rendered.
443
- useEffect(() => {
444
- if (!skipProductStep) {
445
- return;
446
- }
447
- const onlyGroup = allGroups[0];
448
- if (!onlyGroup) {
449
- return;
450
- }
451
- const key = productGroupKey(onlyGroup);
452
- setSelectedProductKey((prev) => (prev === key ? prev : key));
453
- }, [skipProductStep, allGroups, requestType]);
454
-
455
- // Seed (and re-seed on product switch or request-type switch) the
456
- // purpose / correction state from the currently-selected group.
457
- // Single-select means one re-seed per product change — no Map
458
- // reconciliation needed. Clears selections entirely when the user
459
- // de-selects the chip (selectedGroup -> null) or when the request
460
- // type switches into NOMINATION / GRIEVANCE.
461
- useEffect(() => {
462
- if (!selectedGroup || !isProductScopedRequestType(requestType)) {
463
- setSelectedPurposes([]);
464
- setSelectedCorrectionElements([]);
465
- setCorrectionValues({});
466
- return;
467
- }
468
- setSelectedPurposes(buildGroupPurposes(selectedGroup));
469
- setSelectedCorrectionElements([]);
470
- setCorrectionValues({});
471
- }, [selectedGroup, requestType]);
472
-
473
-
474
- // Fetch API translations when language changes (purposes, data elements, grievance options)
475
- useEffect(() => {
476
- const handleLanguageChanged = () => {
477
- const lang = i18n.language;
478
- if (organizationId && workspaceId && data) {
479
- const contact = formData.contact ||
480
- (typeof window !== 'undefined' ? sessionStorage.getItem('email') || '' : '');
481
- if (contact) {
482
- fetchAndApplyTranslations(lang);
483
- }
484
- }
485
- };
486
-
487
- i18n.on('languageChanged', handleLanguageChanged);
488
-
489
- return () => {
490
- i18n.off('languageChanged', handleLanguageChanged);
491
- };
492
- }, [organizationId, workspaceId, data, formData.contact, fetchAndApplyTranslations]);
493
-
494
- useEffect(() => {
495
- const initializePrivacyCenterForm = async () => {
496
- if (isPopup) {
497
- return;
498
- }
499
-
500
- if (!organizationId || !workspaceId) {
501
- onError?.(new Error('Organization or workspace not found'));
502
- return;
503
- }
504
-
505
- const storedContact =
506
- typeof window !== 'undefined'
507
- ? sessionStorage.getItem('email') || ''
508
- : '';
509
-
510
- if (!storedContact) {
511
- onError?.(new Error('Contact email not found'));
512
- return;
513
- }
514
-
515
- try {
516
- // Always fetch English data first and store for submission
517
- const englishData = await getFormData(
518
- organizationId,
519
- workspaceId,
520
- storedContact,
521
- isPopup,
522
- isCaseManagementContext,
523
- authContext,
524
- consentClient
525
- );
526
-
527
- if (!englishData) {
528
- toast.error(t('failedToFetchFormData'));
529
- onError?.(new Error('Failed to fetch form data'));
530
- return;
531
- }
532
-
533
- englishDataRef.current = englishData;
534
-
535
- // If current language is non-English, fetch translated data for display
536
- const langParam = getLangParam(i18n.language);
537
- const displayData = langParam
538
- ? (await getFormData(
539
- organizationId,
540
- workspaceId,
541
- storedContact,
542
- isPopup,
543
- isCaseManagementContext,
544
- authContext,
545
- consentClient,
546
- langParam
547
- )) || englishData
548
- : englishData;
549
-
550
- if (displayData.purposes && Array.isArray(displayData.purposes)) {
551
- const purposesWithDataElements = displayData.purposes.filter(
552
- (p) => p.purpose?.data_elements && p.purpose.data_elements.length > 0
553
- );
554
-
555
- if (purposesWithDataElements.length === 0) {
556
- console.warn('No purposes with data elements found in API response');
557
- }
558
- }
559
-
560
- const pickerData = await withUserConsentPickerGroups(
561
- displayData,
562
- langParam || undefined,
563
- );
564
- setData(pickerData);
565
- setGrievanceOptions(displayData.grievance_options || []);
566
- const initialGrievances = (
567
- displayData.grievance_options || []
568
- ).reduce(
569
- (acc, option) => {
570
- acc[option.value] = false;
571
- return acc;
572
- },
573
- {} as Record<string, boolean>
574
- );
575
- setSelectedGrievances(initialGrievances);
576
- setFormData((prev) => ({
577
- ...prev,
578
- uuid: displayData.uuid || '',
579
- name: displayData.name || '',
580
- contact: displayData.contact || storedContact,
581
- }));
582
- } finally {
583
- }
584
- };
585
-
586
- void initializePrivacyCenterForm();
587
- }, [organizationId, workspaceId, isPopup, authContext, consentClient, withUserConsentPickerGroups]);
588
-
589
- /**
590
- * Maps translated correction element names back to English names using UUID lookup.
591
- */
592
- const getEnglishDataElementName = useCallback((translatedName: string): string => {
593
- if (!englishDataRef.current?.purposes){
594
- return translatedName
595
- }
596
-
597
- // Build a map from translated name -> english name via UUID
598
- const translatedToUuid = new Map<string, string>();
599
- if (data?.purposes) {
600
- data.purposes.forEach((pw) => {
601
- pw.purpose?.data_elements?.forEach((de) => {
602
- translatedToUuid.set(de.name, de.uuid);
603
- });
604
- });
605
- }
606
-
607
- const uuid = translatedToUuid.get(translatedName);
608
- if (!uuid){
609
- return translatedName
610
- }
611
-
612
- for (const pw of englishDataRef.current.purposes) {
613
- const englishDe = pw.purpose?.data_elements?.find((de) => de.uuid === uuid);
614
- if (englishDe){
615
- return englishDe.name
616
- }
617
- }
618
- return translatedName;
619
- }, [data]);
620
-
621
- // Purpose names selected in an ERASURE request — surfaced in the
622
- // consent-revoke modal and the post-submit notice. When the list is
623
- // empty or too long to enumerate, fall back to a generic phrase.
624
- const selectedErasurePurposeList = useMemo(
625
- () =>
626
- selectedPurposes
627
- .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
628
- .map((p) => p.name)
629
- .filter(Boolean),
630
- [selectedPurposes]
631
- );
632
-
633
- const selectedErasurePurposeNames = useMemo(() => {
634
- if (
635
- selectedErasurePurposeList.length === 0 ||
636
- selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL
637
- ) {
638
- return t('revokeConsentModalYourPurposes');
639
- }
640
- return selectedErasurePurposeList.join(', ');
641
- }, [selectedErasurePurposeList, t]);
642
-
643
- const handleSubmitClick = () => {
644
- if (!isFormValid) {
645
- return;
646
- }
647
- // ERASURE asks the data principal whether to also revoke consent on
648
- // fulfilment — the choice is made in the modal and threaded into the
649
- // payload via handleErasureModalChoice. Other request types
650
- // (ACCESS / CORRECTION / NOMINATION / GRIEVANCE) submit directly.
651
- if (requestType === RequestTypeEnum.ERASURE) {
652
- setIsErasureConsentModalOpen(true);
653
- return;
654
- }
655
- setRevokeConsentOnFulfilment(false);
656
- void handleSubmit(false);
657
- };
658
-
659
- const handleErasureModalChoice = (revoke: boolean) => {
660
- setRevokeConsentOnFulfilment(revoke);
661
- setIsErasureConsentModalOpen(false);
662
- void handleSubmit(revoke);
663
- };
664
-
665
- const handleErasureModalClose = () => {
666
- setIsErasureConsentModalOpen(false);
667
- };
668
-
669
- /**
670
- * Submit the form as a single case. Builds the payload from the
671
- * currently-selected product group (for ACCESS/ERASURE/CORRECTION) or
672
- * from the org-level state (for NOMINATION/GRIEVANCE), then POSTs once
673
- * via `createCaseRequest`. The 1-form -> 1-case rule means no
674
- * fan-out and no partial-failure UI to maintain.
675
- *
676
- * Anchors:
677
- * - `product_uuid` is set ONLY for product-scoped request types and
678
- * ONLY from `selectedGroup.product_uuid`. NOMINATION/GRIEVANCE
679
- * never carry a product anchor.
680
- * - `data_principal_uuid` is set ONLY for nominated cards (acting
681
- * on someone else's behalf — the nominator's ConsentUser uuid).
682
- * Direct cards omit the field so the backend defaults it from
683
- * the JWT-derived requestor_uuid. `formData.uuid` is the
684
- * FormDataOut session uuid, NOT the requestor's ConsentUser uuid,
685
- * and would be rejected by the backend's ConsentUser lookup.
686
- */
687
- const handleSubmit = async (revokeConsent: boolean = false) => {
688
- if (!isFormValid) {
689
- return;
690
- }
691
-
692
- const isProductScoped = isProductScopedRequestType(requestType);
693
- // The synthetic legacy-flat group has no server-side product, so it must
694
- // not anchor the case — those submissions keep the pre-grouping
695
- // org-level shape (`product_uuid` is optional on the backend).
696
- const isLegacyFlatSelection =
697
- selectedGroup?.product_uuid === LEGACY_PRODUCT_GROUP_UUID;
698
- const anchorGroup =
699
- isProductScoped && !isLegacyFlatSelection ? selectedGroup : null;
700
-
701
- const requestDetails: {
702
- purposes: UpdatePurpose[];
703
- correction_data: UpdateCorrectionDataItem[];
704
- grievance_types: GrievanceTypeElement[];
705
- nomination_data?: NominationData;
706
- } = {
707
- purposes: [],
708
- correction_data: [],
709
- grievance_types: [],
710
- };
711
-
712
- if (
713
- requestType === RequestTypeEnum.ACCESS ||
714
- requestType === RequestTypeEnum.ERASURE
715
- ) {
716
- requestDetails.purposes = selectedPurposes
717
- .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
718
- .map((p) => ({
719
- purpose_uuid: p.uuid,
720
- data_element_uuids: p.data_elements
721
- .filter((de) => de.selected)
722
- .map((de) => de.uuid),
723
- }));
724
- }
725
-
726
- if (requestType === RequestTypeEnum.CORRECTION) {
727
- requestDetails.correction_data = selectedCorrectionElements.map(
728
- (name) => ({
729
- name: getEnglishDataElementName(name),
730
- curr_value: correctionValues[name]?.current ?? '',
731
- new_value: correctionValues[name]?.updated ?? '',
732
- }),
733
- );
734
- }
735
-
736
- if (requestType === RequestTypeEnum.GRIEVANCE) {
737
- requestDetails.grievance_types = Object.entries(selectedGrievances)
738
- .filter(([, checked]) => checked)
739
- .map(([key]) => ({
740
- grievance: key as GrievanceValue,
741
- }));
742
- }
743
-
744
- if (requestType === RequestTypeEnum.NOMINATION) {
745
- requestDetails.nomination_data = {
746
- nominee_email: nominationData.nominee_email.trim(),
747
- ...(nominationData.nominee_mobile?.trim()
748
- ? { nominee_mobile: nominationData.nominee_mobile.trim() }
749
- : {}),
750
- };
751
- }
752
-
753
- const payload: UserDataRequest = {
754
- name: ' ',
755
- contact: formData.contact,
756
- request_type: requestType?.toLowerCase() as RequestTypeEnum,
757
- uuid: formData.uuid,
758
- supporting_docs_uuids: [...uploadedDocumentId],
759
- request_details: requestDetails,
760
- time_period:
761
- requestType === RequestTypeEnum.ACCESS
762
- ? parseInt(timePeriod || '30', 10)
763
- : 0,
764
- requestor_note: formData.additionalNote || '',
765
- ...(requestType === RequestTypeEnum.ERASURE
766
- ? { revoke_consent_on_fulfilment: revokeConsent }
767
- : {}),
768
- ...(anchorGroup ? { product_uuid: anchorGroup.product_uuid } : {}),
769
- ...(anchorGroup?.nominator
770
- ? { data_principal_uuid: anchorGroup.nominator.uuid }
771
- : {}),
772
- };
773
-
774
- try {
775
- setIsLoading(true);
776
- const response = await createCaseRequest(
777
- organizationId || '',
778
- workspaceId || '',
779
- payload,
780
- isPopup,
781
- isCaseManagementContext, // Use session auth if in case management context
782
- authContext,
783
- consentClient,
784
- );
785
- const extracted = extractCaseId(response);
786
- const caseId = extracted?.case_id || extracted?.uuid || null;
787
-
788
- if (caseId) {
789
- queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
790
- setSubmittedCase({
791
- case_id: caseId,
792
- uuid: extracted?.uuid ?? caseId,
793
- product_name: anchorGroup?.product_name ?? '',
794
- product_uuid: anchorGroup?.product_uuid ?? '',
795
- });
796
- setIsSubmitted(true);
797
- toast.success(t('requestSubmittedSuccess'));
798
-
799
- if (isPopup) {
800
- onCreateRequest?.();
801
- // Only dismiss the popup on success. Closing in `finally`
802
- // would discard the operator's filled form on a transient
803
- // failure with no way to retry.
804
- onClose?.();
805
- }
806
- } else {
807
- toast.error(t('requestSubmissionFailed'));
808
- }
809
- } catch (error) {
810
- // `createCaseRequest` returns null on non-auth errors (per its
811
- // documented contract) — those land in the `else` branch above
812
- // with a generic toast. This catch only fires on unexpected
813
- // exceptions; surface whatever message the error carries.
814
- console.error('Error submitting request:', error);
815
- const message =
816
- error instanceof Error && error.message
817
- ? error.message
818
- : t('submissionFailed');
819
- toast.error(message);
820
- } finally {
821
- setIsLoading(false);
822
- }
823
- };
824
-
825
- const isRequestDetailsValid = useCallback(() => {
826
- if (!requestType) {
827
- return false;
828
- }
829
-
830
- // Product-anchor validity gate: ACCESS/ERASURE/CORRECTION require a
831
- // selected product before submit is allowed. The backend leaves
832
- // `product_uuid` Optional for back-compat, so we enforce the anchor
833
- // on the client — a product-scoped case without an anchor would
834
- // land as an org-level case with no scoping, which violates the
835
- // 1-form -> 1-case -> 1-product contract.
836
- if (isProductScopedRequestType(requestType)) {
837
- if (allGroups.length === 0) {
838
- // No consent groups for this DP at all — the picker shows its
839
- // empty state and we block submit. The DP has nothing they can
840
- // raise a product-scoped case against.
841
- return false;
842
- }
843
- if (!selectedGroup) {
844
- // Groups exist but the user hasn't picked one. With the
845
- // single-group auto-select effect this should be rare in
846
- // practice, but the gate must still reject.
847
- return false;
848
- }
849
- if (requestType === RequestTypeEnum.CORRECTION) {
850
- return (
851
- selectedCorrectionElements.length > 0 &&
852
- selectedCorrectionElements.every(
853
- (name) =>
854
- correctionValues[name]?.current?.trim() &&
855
- correctionValues[name]?.updated?.trim(),
856
- )
857
- );
858
- }
859
- return selectedPurposes.some(
860
- (p) => p.selected || p.data_elements.some((de) => de.selected),
861
- );
862
- }
863
-
864
- switch (requestType) {
865
- case RequestTypeEnum.GRIEVANCE:
866
- return Object.values(selectedGrievances).some(Boolean);
867
-
868
- case RequestTypeEnum.NOMINATION:
869
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
870
- nominationData.nominee_email.trim(),
871
- );
872
-
873
- default:
874
- return false;
875
- }
876
- }, [
877
- requestType,
878
- allGroups,
879
- selectedGroup,
880
- selectedPurposes,
881
- selectedCorrectionElements,
882
- correctionValues,
883
- selectedGrievances,
884
- nominationData,
885
- ]);
886
-
887
- const isFormValid = useMemo(() => {
888
- return (
889
- (!isPopup || isPopupFormEnabled) &&
890
- !!formData.contact?.trim() &&
891
- !!requestType &&
892
- isRequestDetailsValid() &&
893
- formData.confirmCheckBox
894
- );
895
- }, [
896
- isPopup,
897
- isPopupFormEnabled,
898
- formData.contact,
899
- formData.confirmCheckBox,
900
- requestType,
901
- isRequestDetailsValid,
902
- ]);
903
-
904
- const handleFileUpload = async (
905
- file: File,
906
- isPublic: boolean,
907
- caseId: string
908
- ) => {
909
- setIsDocumentUploading(true);
910
- try {
911
- const metaData = {
912
- group_by: 'privacy',
913
- case_uuid: caseId,
914
- product: 'privacy_center',
915
- };
916
-
917
- const payload = {
918
- organization_uuid: organizationId || '',
919
- space: `privacy/${caseId}`,
920
- uuid: crypto.randomUUID(),
921
- is_public: isPublic,
922
- allow_ai_processing: true,
923
- ...(metaData ? { metadata: metaData } : {}),
924
- };
925
-
926
- // Create FormData on client side - this can be passed to server actions
927
- const formData = new FormData();
928
- formData.append('file', file);
929
- formData.append('payload', JSON.stringify(payload));
930
-
931
- // Ensure we have a valid token before uploading
932
- if (!organizationId || !workspaceId) {
933
- throw new Error('Organization ID and Workspace ID are required for file upload');
934
- }
935
-
936
- const authToken = await ensureValidToken(organizationId, workspaceId, orgSlug, authContext, consentClient);
937
- if (!authToken) {
938
- throw new Error('Authentication token is required for file upload');
939
- }
940
-
941
- // uploadFileWithS2S is a client-side function that uses JWT Bearer token authentication
942
- const uploadedDoc = await uploadFileWithS2S({
943
- formData,
944
- fileName: file.name,
945
- authToken,
946
- baseUrl,
947
- });
948
-
949
- if (!uploadedDoc.success) {
950
- toast.error(t('failedToUploadFile', { fileName: file.name }));
951
- throw new Error(uploadedDoc.error);
952
- }
953
-
954
- if (uploadedDoc.uuid) {
955
- setUploadedDocuments((prev) => [
956
- ...prev,
957
- { uuid: uploadedDoc.uuid!, name: file.name },
958
- ]);
959
- setUploadedDocumentId((prevIds) => [...prevIds, uploadedDoc.uuid!]);
960
- setIsDocument(true);
961
- toast.success(t('fileUploadedSuccess'));
962
- }
963
- } catch (error) {
964
- console.error('Upload error:', error);
965
- toast.error(t('uploadError', { error: String(error) }));
966
- return null;
967
- } finally {
968
- setIsDocumentUploading(false);
969
- }
970
- };
971
-
972
- const handleFileChange = async (
973
- event: React.ChangeEvent<HTMLInputElement>
974
- ) => {
975
- const files = Array.from(event.target.files || []);
976
- handleFileUpload(files[0], true, formData.uuid);
977
- resetFileInput();
978
- };
979
-
980
- const handleRemoveDocument = (uuidToRemove: string) => {
981
- setUploadedDocuments((prev) =>
982
- prev.filter((doc) => doc.uuid !== uuidToRemove)
983
- );
984
- setUploadedDocumentId((prev) => prev.filter((id) => id !== uuidToRemove));
985
- };
986
-
987
- const handleVerifyContact = async () => {
988
- if (!isPopup) {
989
- return
990
- };
991
- if (!formData.contact) {
992
- toast.error(t('pleaseEnterContactToVerify'));
993
- return;
994
- }
995
- if (!organizationId || !workspaceId) {
996
- toast.error(t('missingOrgOrWorkspace'));
997
- return;
998
- }
999
-
1000
- setIsVerifyingContact(true);
1001
- try {
1002
- // Always fetch English data first and store for submission
1003
- const englishData = await getFormData(
1004
- organizationId,
1005
- workspaceId,
1006
- formData.contact,
1007
- isPopup,
1008
- isCaseManagementContext,
1009
- authContext,
1010
- consentClient
1011
- );
1012
-
1013
- if (englishData) {
1014
- englishDataRef.current = englishData;
1015
-
1016
- // If current language is non-English, fetch translated data for display
1017
- const langParam = getLangParam(i18n.language);
1018
- const displayData = langParam
1019
- ? (await getFormData(
1020
- organizationId,
1021
- workspaceId,
1022
- formData.contact,
1023
- isPopup,
1024
- isCaseManagementContext,
1025
- authContext,
1026
- consentClient,
1027
- langParam
1028
- )) || englishData
1029
- : englishData;
1030
-
1031
- if (displayData.purposes && Array.isArray(displayData.purposes)) {
1032
- const purposesWithDataElements = displayData.purposes.filter(
1033
- (p) => p.purpose?.data_elements && p.purpose.data_elements.length > 0
1034
- );
1035
-
1036
- if (purposesWithDataElements.length === 0) {
1037
- console.warn('No purposes with data elements found in API response (popup)');
1038
- }
1039
- }
1040
-
1041
- setData(displayData);
1042
- setGrievanceOptions(displayData.grievance_options || []);
1043
- const initialGrievances = (displayData.grievance_options || []).reduce(
1044
- (acc, option) => {
1045
- acc[option.value] = false;
1046
- return acc;
1047
- },
1048
- {} as Record<string, boolean>
1049
- );
1050
- setSelectedGrievances(initialGrievances);
1051
- setFormData((prev) => ({
1052
- ...prev,
1053
- uuid: displayData.uuid || '',
1054
- name: displayData.name || '',
1055
- contact: displayData.contact || prev.contact,
1056
- }));
1057
- // Translations are handled locally via i18next, no API call needed
1058
- setIsPopupFormEnabled(true);
1059
- toast.success(t('contactVerifiedSuccess'));
1060
- } else {
1061
- setIsPopupFormEnabled(false);
1062
- toast.error(t('unableToVerifyContact'));
1063
- }
1064
- } catch (error: any) {
1065
- if (error.response?.status === 404) {
1066
- toast.info(t('noUserFoundForContact', { contact: formData.contact }));
1067
- onClose?.();
1068
- } else {
1069
- toast.error(t('failedToVerifyContact'));
1070
- setIsVerifyingContact(false);
1071
- }
1072
- } finally {
1073
- setIsVerifyingContact(false);
1074
- }
1075
- };
1076
-
1077
- return (
1078
- <>
1079
- {isSubmitted && submittedCase && !isPopup ? (
1080
- <RequestSubmit
1081
- result={submittedCase}
1082
- contact={formData?.contact}
1083
- onCreateRequest={onCreateRequest}
1084
- revokeConsentOnFulfilment={revokeConsentOnFulfilment}
1085
- revokedPurposeNames={selectedErasurePurposeNames}
1086
- />
1087
- ) : (
1088
- <div>
1089
- <div>
1090
- {!isPopup && (
1091
- <p style={{
1092
- marginTop: 0,
1093
- marginBottom: getVar('--pc-spacing-sm'),
1094
- lineHeight: 1.5,
1095
- fontSize: pcStyles.textSm.fontSize,
1096
- color: getVar('--pc-text-secondary'),
1097
- }}>{t('description')}</p>
1098
- )}
1099
- <div
1100
- style={{
1101
- backgroundColor: getVar('--pc-bg-primary'),
1102
- ...(isPopup ? {
1103
- marginTop: 0,
1104
- marginBottom: 0,
1105
- borderRadius: pcStyles.borderRadiusXl,
1106
- } : {
1107
- marginTop: pcStyles.mtSm.marginTop,
1108
- marginBottom: pcStyles.mbSm.marginBottom,
1109
- borderRadius: pcStyles.borderRadiusXl,
1110
- border: `1px solid ${getVar('--pc-border-color')}`,
1111
- boxShadow: getVar('--pc-shadow-sm'),
1112
- }),
1113
- }}
1114
- >
1115
- <FormBlockWrapper inputClassName="mx-auto">
1116
- <div
1117
- style={{
1118
- ...pcStyles.flex,
1119
- ...pcStyles.itemsCenter,
1120
- ...pcStyles.justifyBetween,
1121
- backgroundColor: `var(--pc-bg-secondary)`,
1122
- padding: getVar('--pc-spacing-md'),
1123
- width: '100%',
1124
- overflow: 'visible',
1125
- ...(isPopup ? {
1126
- position: 'sticky',
1127
- top: 0,
1128
- zIndex: 20,
1129
- borderBottom: `1px solid var(--pc-border-color)`,
1130
- } : {
1131
- position: 'relative',
1132
- borderRadius: `${getVar('--pc-border-radius-lg')} ${getVar('--pc-border-radius-lg')} 0 0`,
1133
- }),
1134
- }}
1135
- className="pc-padding-responsive"
1136
- >
1137
- <p style={{
1138
- fontSize: pcStyles.textSm.fontSize,
1139
- fontWeight: pcStyles.fontWeightSemibold.fontWeight,
1140
- color: `var(--pc-text-primary)`,
1141
- margin: 0,
1142
- whiteSpace: 'nowrap',
1143
- flexShrink: 0,
1144
- flexGrow: 0,
1145
- }} className="pc-text-responsive">
1146
- {t('raiseDataRequest')}
1147
- </p>
1148
- {isPopup && (
1149
- <PCButton
1150
- variant="text"
1151
- onClick={onClose}
1152
- style={{ padding: 0, flexShrink: 0, marginLeft: 'auto' }}
1153
- >
1154
- <FiX size={20} />
1155
- </PCButton>
1156
- )}
1157
- </div>
1158
- <div
1159
- style={{
1160
- ...pcStyles.flex,
1161
- ...pcStyles.flexCol,
1162
- gap: getVar('--pc-spacing-lg'),
1163
- padding: getVar('--pc-spacing-lg'),
1164
- }}
1165
- className="pc-padding-responsive pc-spacing-responsive"
1166
- >
1167
- <div style={{
1168
- ...pcStyles.flex,
1169
- width: '100%',
1170
- ...pcStyles.itemsEnd,
1171
- gap: getVar('--pc-spacing-sm'),
1172
- flexWrap: 'wrap',
1173
- }}>
1174
- <PCInput
1175
- style={{ flex: 1, minWidth: '200px' }}
1176
- label={
1177
- <span>
1178
- {t('contact')}
1179
- <span style={{ color: getVar('--pc-error') }}>*</span>
1180
- </span>
1181
- }
1182
- placeholder={t('contactPlaceholder')}
1183
- value={formData.contact}
1184
- readOnly={!isPopup}
1185
- onChange={(e) => {
1186
- if (!isPopup){
1187
- return
1188
- };
1189
- setFormData({
1190
- ...formData,
1191
- contact: e.target.value,
1192
- });
1193
- setIsPopupFormEnabled(false);
1194
- }}
1195
- />
1196
- {isPopup && !isPopupFormEnabled && (
1197
- <div style={{ ...pcStyles.flex, ...pcStyles.justifyEnd }}>
1198
- <PCButton
1199
- size="sm"
1200
- onClick={handleVerifyContact}
1201
- style={{ height: '2.25rem', flexShrink: 0 }}
1202
- disabled={!formData.contact || isVerifyingContact}
1203
- >
1204
- {isVerifyingContact ? t('verifying') : t('verify')}
1205
- </PCButton>
1206
- </div>
1207
- )}
1208
- </div>
1209
- <PCSelect
1210
- label={
1211
- <span>
1212
- {t('requestType')}
1213
- <span style={{ color: getVar('--pc-error') }}>*</span>
1214
- </span>
1215
- }
1216
- options={Object.values(RequestTypeEnum).map((type) => ({
1217
- value: type,
1218
- label: requestTypeLabels[type],
1219
- }))}
1220
- value={
1221
- Object.values(RequestTypeEnum)
1222
- .map((type) => ({
1223
- value: type,
1224
- label: requestTypeLabels[type],
1225
- }))
1226
- .find((opt) => opt.value === requestType) || null
1227
- }
1228
- onChange={(value) => {
1229
- if (value) {
1230
- setRequestType(value.value as RequestTypeEnum);
1231
- setTimePeriod('');
1232
- setIsRequestDetailsExpanded(true);
1233
- }
1234
- }}
1235
- placeholder={t('selectRequestType')}
1236
- disabled={isPopup && !isPopupFormEnabled}
1237
- />
1238
- <div
1239
- style={{
1240
- ...pcStyles.flex,
1241
- ...pcStyles.flexCol,
1242
- borderRadius: pcStyles.borderRadiusLg,
1243
- border: `1px solid ${getVar('--pc-border-color')}`,
1244
- backgroundColor: getVar('--pc-bg-secondary'),
1245
- overflow: 'hidden',
1246
- ...(isPopup && !isPopupFormEnabled ? {
1247
- pointerEvents: 'none',
1248
- opacity: 0.6,
1249
- } : {}),
1250
- }}
1251
- >
1252
- <div style={{
1253
- ...pcStyles.flex,
1254
- ...pcStyles.itemsCenter,
1255
- ...pcStyles.justifyBetween,
1256
- padding: getVar('--pc-spacing-md'),
1257
- borderBottom: isRequestDetailsExpanded ? `1px solid ${getVar('--pc-border-color')}` : 'none',
1258
- }}>
1259
- <p style={{
1260
- fontWeight: pcStyles.fontWeightSemibold.fontWeight,
1261
- color: `var(--pc-text-primary)`,
1262
- fontSize: pcStyles.textSm.fontSize,
1263
- margin: 0,
1264
- }}>
1265
- {t('requestDetails')}{' '}
1266
- <span style={{ color: getVar('--pc-error') }}>*</span>
1267
- </p>
1268
- <PCButton
1269
- variant="text"
1270
- size="sm"
1271
- style={{ height: 'auto', padding: 0, color: 'var(--pc-text-secondary)', flexShrink: 0 }}
1272
- onClick={() =>
1273
- setIsRequestDetailsExpanded(
1274
- !isRequestDetailsExpanded
1275
- )
1276
- }
1277
- >
1278
- {isRequestDetailsExpanded ? (
1279
- <FiChevronUp size={20} />
1280
- ) : (
1281
- <FiChevronDown size={20} />
1282
- )}
1283
- </PCButton>
1284
- </div>
1285
- {isRequestDetailsExpanded && (
1286
- <div style={{
1287
- padding: getVar('--pc-spacing-md'),
1288
- }}>
1289
- {isProductScopedRequestType(requestType) ? (
1290
- <div
1291
- style={{
1292
- ...pcStyles.flex,
1293
- ...pcStyles.flexCol,
1294
- gap: getVar('--pc-spacing-md'),
1295
- }}
1296
- >
1297
- {!skipProductStep && (
1298
- <ProductSelectionSection
1299
- directGroups={data?.direct_groups ?? []}
1300
- nominatedGroups={data?.nominated_groups ?? []}
1301
- selectedProductKey={selectedProductKey}
1302
- onSelect={handleProductSelect}
1303
- requestTypeLabel={requestTypeLabels[requestType]}
1304
- isTranslating={isTranslating}
1305
- />
1306
- )}
1307
- {selectedGroup && (() => {
1308
- const key = productGroupKey(selectedGroup);
1309
- const isCorrection =
1310
- requestType === RequestTypeEnum.CORRECTION;
1311
- const groupDataElements = isCorrection
1312
- ? buildGroupDataElements(selectedGroup)
1313
- : [];
1314
- const nominatorLabel = selectedGroup.nominator
1315
- ? selectedGroup.nominator.name ??
1316
- selectedGroup.nominator.email ??
1317
- ''
1318
- : '';
1319
-
1320
- return (
1321
- <div
1322
- className="pc-product-purpose-panel"
1323
- data-testid={`per-product-purposes-${key}`}
1324
- >
1325
- <div className="pc-product-purpose-panel-header">
1326
- <div className="pc-product-purpose-panel-title-row">
1327
- <PCText
1328
- as="span"
1329
- size="base"
1330
- weight="semibold"
1331
- color="primary"
1332
- className="pc-product-purpose-panel-title"
1333
- >
1334
- {selectedGroup.product_name}
1335
- </PCText>
1336
- {selectedGroup.nominator && (
1337
- <PCText
1338
- as="span"
1339
- size="xs"
1340
- weight="medium"
1341
- color="secondary"
1342
- className="pc-product-purpose-panel-nominator"
1343
- >
1344
- {t('actingOnBehalfOfShort', {
1345
- nominator: nominatorLabel,
1346
- })}
1347
- </PCText>
1348
- )}
1349
- </div>
1350
- <PCText
1351
- as="span"
1352
- size="xs"
1353
- color="secondary"
1354
- className="pc-product-purpose-panel-subtitle"
1355
- >
1356
- {t('selectPurposesForThisProduct')}
1357
- </PCText>
1358
- </div>
1359
- <div
1360
- className="pc-product-purpose-panel-body"
1361
- data-testid={`per-product-purposes-body-${key}`}
1362
- >
1363
- {isCorrection ? (
1364
- <CorrectionDataSection
1365
- dataElements={groupDataElements}
1366
- selected={selectedCorrectionElements}
1367
- setSelected={setSelectedCorrectionElements}
1368
- correctionValues={correctionValues}
1369
- setCorrectionValues={setCorrectionValues}
1370
- isTranslating={isTranslating}
1371
- />
1372
- ) : (
1373
- <PurposeSelectionSection
1374
- purposes={selectedPurposes}
1375
- setPurposes={setSelectedPurposes}
1376
- requestType={requestType}
1377
- isTranslating={isTranslating}
1378
- />
1379
- )}
1380
- </div>
1381
- </div>
1382
- );
1383
- })()}
1384
- </div>
1385
- ) : requestType === RequestTypeEnum.GRIEVANCE ? (
1386
- <GrievanceDataSection
1387
- grievanceOptions={grievanceOptions}
1388
- selectedGrievances={selectedGrievances}
1389
- setSelectedGrievances={setSelectedGrievances}
1390
- isTranslating={isTranslating}
1391
- />
1392
- ) : requestType === RequestTypeEnum.NOMINATION ? (
1393
- <NominationDataSection
1394
- nominationData={nominationData}
1395
- setNominationData={setNominationData}
1396
- isTranslating={isTranslating}
1397
- />
1398
- ) : (
1399
- <div
1400
- style={{
1401
- color: getVar('--pc-text-secondary'),
1402
- ...(isPopup ? {
1403
- padding: getVar('--pc-spacing-md'),
1404
- } : {
1405
- ...pcStyles.flex,
1406
- height: '8rem',
1407
- ...pcStyles.itemsCenter,
1408
- ...pcStyles.justifyCenter,
1409
- padding: getVar('--pc-spacing-lg'),
1410
- }),
1411
- textAlign: 'center',
1412
- }}
1413
- >
1414
- {t('selectRequestType')}
1415
- </div>
1416
- )}
1417
- </div>
1418
- )}
1419
- </div>
1420
- <div
1421
- style={{
1422
- ...pcStyles.flex,
1423
- ...pcStyles.flexCol,
1424
- gap: getVar('--pc-spacing-sm'),
1425
- ...(isPopup && !isPopupFormEnabled ? {
1426
- pointerEvents: 'none',
1427
- opacity: 0.6,
1428
- } : {}),
1429
- }}
1430
- >
1431
- <p style={{
1432
- fontWeight: pcStyles.fontWeightSemibold.fontWeight,
1433
- color: `var(--pc-text-primary)`,
1434
- fontSize: pcStyles.textSm.fontSize,
1435
- margin: 0,
1436
- }}>
1437
- {t('supportingDocumentation')}
1438
- </p>
1439
- <input
1440
- ref={fileInputRef}
1441
- id="privacy-file-upload"
1442
- type="file"
1443
- onChange={(e) => handleFileChange(e)}
1444
- style={{ display: 'none' }}
1445
- />
1446
- <div
1447
- style={{
1448
- ...pcStyles.flex,
1449
- flexWrap: 'wrap',
1450
- ...pcStyles.itemsStart,
1451
- borderRadius: pcStyles.borderRadiusMd,
1452
- border: `1px solid var(--pc-border-color)`,
1453
- backgroundColor: `var(--pc-bg-secondary)`,
1454
- padding: getVar('--pc-spacing-md'),
1455
- minHeight: '100px',
1456
- ...(isPopup ? {
1457
- gap: '0.5rem',
1458
- ...pcStyles.overflowYAuto,
1459
- } : {
1460
- gap: getVar('--pc-spacing-sm'),
1461
- }),
1462
- }}
1463
- >
1464
- {/* Always render documents list container to prevent layout shift */}
1465
- {(isDocument && uploadedDocuments.length > 0) && (
1466
- <div
1467
- style={{
1468
- ...pcStyles.flex,
1469
- ...pcStyles.gapSm,
1470
- ...(isPopup ? { flexWrap: 'wrap' } : {}),
1471
- width: '100%',
1472
- }}
1473
- >
1474
- {uploadedDocuments.map((doc) => (
1475
- <div
1476
- key={doc.uuid}
1477
- style={{
1478
- position: 'relative',
1479
- ...pcStyles.flex,
1480
- ...pcStyles.itemsCenter,
1481
- ...pcStyles.gapXs,
1482
- borderRadius: pcStyles.borderRadiusMd,
1483
- border: `1px solid var(--pc-border-color)`,
1484
- backgroundColor: `var(--pc-bg-primary)`,
1485
- padding: getVar('--pc-spacing-sm'),
1486
- }}
1487
- >
1488
- <img
1489
- src={fileAttachIcon}
1490
- alt="fileAttach"
1491
- width={24}
1492
- height={24}
1493
- />
1494
- <p
1495
- style={{
1496
- fontWeight: pcStyles.fontWeightMedium.fontWeight,
1497
- color: `var(--pc-text-primary)`,
1498
- ...(isPopup ? {
1499
- maxWidth: '100px',
1500
- overflow: 'hidden',
1501
- textOverflow: 'ellipsis',
1502
- whiteSpace: 'nowrap',
1503
- fontSize: pcStyles.textXs.fontSize,
1504
- } : {
1505
- fontSize: pcStyles.textSm.fontSize,
1506
- }),
1507
- }}
1508
- >
1509
- {doc.name}
1510
- </p>
1511
- <div
1512
- style={{
1513
- position: 'absolute',
1514
- right: '-0.25rem',
1515
- top: '-0.5rem',
1516
- cursor: 'pointer',
1517
- }}
1518
- onClick={(e) => {
1519
- e.stopPropagation();
1520
- handleRemoveDocument(doc.uuid);
1521
- }}
1522
- >
1523
- <div style={{
1524
- borderRadius: '50%',
1525
- border: `1px solid var(--pc-border-color)`,
1526
- backgroundColor: `var(--pc-bg-secondary)`,
1527
- padding: '0.125rem',
1528
- }}>
1529
- <AiOutlineClose size={12} style={{ color: 'var(--pc-error)' }} />
1530
- </div>
1531
- </div>
1532
- </div>
1533
- ))}
1534
- </div>
1535
- )}
1536
-
1537
- {/* Upload button or loading state - always in same position */}
1538
- <div style={{
1539
- width: uploadedDocuments.length > 0 ? 'auto' : '100%',
1540
- ...pcStyles.flex,
1541
- ...pcStyles.itemsCenter,
1542
- ...pcStyles.justifyCenter,
1543
- minHeight: uploadedDocuments.length > 0 ? 'auto' : '100px',
1544
- }}>
1545
- {isDocumentUploading ? (
1546
- <div style={{
1547
- padding: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-lg'),
1548
- borderRadius: pcStyles.borderRadiusMd,
1549
- border: uploadedDocuments.length > 0 ? `1px solid var(--pc-border-color)` : 'none',
1550
- backgroundColor: `var(--pc-bg-primary)`,
1551
- fontWeight: pcStyles.fontWeightSemibold.fontWeight,
1552
- color: `var(--pc-text-primary)`,
1553
- ...pcStyles.flex,
1554
- ...pcStyles.itemsCenter,
1555
- ...pcStyles.justifyCenter,
1556
- gap: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-md'),
1557
- width: 'auto',
1558
- }}>
1559
- <PCLoader variant="spinner" size="sm" />
1560
- <span>{t('uploading')}</span>
1561
- </div>
1562
- ) : (
1563
- <div
1564
- style={{
1565
- ...pcStyles.flex,
1566
- width: uploadedDocuments.length > 0 ? 'auto' : '100%',
1567
- cursor: 'pointer',
1568
- ...pcStyles.itemsCenter,
1569
- ...pcStyles.justifyCenter,
1570
- gap: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-md'),
1571
- borderRadius: pcStyles.borderRadiusMd,
1572
- padding: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-lg'),
1573
- border: uploadedDocuments.length > 0 ? `1px solid var(--pc-border-color)` : 'none',
1574
- backgroundColor: uploadedDocuments.length > 0 ? `var(--pc-bg-primary)` : 'transparent',
1575
- transition: 'all 0.2s ease',
1576
- }}
1577
- onMouseEnter={(e) => {
1578
- e.currentTarget.style.backgroundColor = uploadedDocuments.length > 0
1579
- ? `var(--pc-bg-hover)`
1580
- : `var(--pc-bg-hover)`;
1581
- if (uploadedDocuments.length > 0) {
1582
- e.currentTarget.style.borderColor = `var(--pc-primary)`;
1583
- }
1584
- }}
1585
- onMouseLeave={(e) => {
1586
- e.currentTarget.style.backgroundColor = uploadedDocuments.length > 0
1587
- ? `var(--pc-bg-primary)`
1588
- : 'transparent';
1589
- if (uploadedDocuments.length > 0) {
1590
- e.currentTarget.style.borderColor = `var(--pc-border-color)`;
1591
- }
1592
- }}
1593
- role="button"
1594
- tabIndex={0}
1595
- aria-label={t('uploadFile')}
1596
- onClick={() =>
1597
- document
1598
- .getElementById('privacy-file-upload')
1599
- ?.click()
1600
- }
1601
- onKeyDown={(e) => {
1602
- if (e.key === 'Enter' || e.key === ' ') {
1603
- e.preventDefault();
1604
- document.getElementById('privacy-file-upload')?.click();
1605
- }
1606
- }}
1607
- >
1608
- {uploadedDocuments.length === 0 ? (
1609
- <>
1610
- <div>
1611
- <img
1612
- src={uploadFileIcon}
1613
- width={44}
1614
- height={44}
1615
- alt="upload"
1616
- />
1617
- </div>
1618
- <span style={{
1619
- color: 'var(--pc-text-secondary)',
1620
- fontSize: pcStyles.textSm.fontSize,
1621
- }}>
1622
- {t('uploadFile')}
1623
- </span>
1624
- </>
1625
- ) : (
1626
- <div style={{ ...pcStyles.flex, ...pcStyles.itemsCenter, ...pcStyles.justifyCenter, ...pcStyles.gapSm }}>
1627
- <PiUploadBold
1628
- size={isPopup ? 20 : 16}
1629
- style={{ color: 'var(--pc-text-secondary)' }}
1630
- />
1631
- {!isPopup && (
1632
- <p style={{
1633
- fontWeight: pcStyles.fontWeightMedium.fontWeight,
1634
- color: `var(--pc-text-primary)`,
1635
- }}>
1636
- {t('uploadFileButton')}
1637
- </p>
1638
- )}
1639
- </div>
1640
- )}
1641
- </div>
1642
- )}
1643
- </div>
1644
- </div>
1645
- </div>
1646
- {requestType === RequestTypeEnum.ACCESS && (
1647
- <PCSelect
1648
- label={t('timePeriod')}
1649
- options={timePeriodOptions}
1650
- value={timePeriodOptions.find(
1651
- (opt) => opt.value === timePeriod
1652
- ) || null}
1653
- onChange={(value) => {
1654
- if (value) {
1655
- setTimePeriod(value.value as string);
1656
- }
1657
- }}
1658
- disabled={isPopup && !isPopupFormEnabled}
1659
- />
1660
- )}
1661
- <PCTextarea
1662
- label={t('additionalNote')}
1663
- placeholder={t('reasonOptional')}
1664
- value={formData.additionalNote}
1665
- onChange={(e) =>
1666
- setFormData({
1667
- ...formData,
1668
- additionalNote: e.target.value,
1669
- })
1670
- }
1671
- style={isPopup ? { minHeight: '60px' } : {}}
1672
- disabled={isPopup && !isPopupFormEnabled}
1673
- />
1674
- <PCCheckbox
1675
- size="sm"
1676
- label={
1677
- <span>
1678
- {t('confirmCheckBox')}{' '}
1679
- <span style={{ color: getVar('--pc-error') }}>*</span>
1680
- </span>
1681
- }
1682
- checked={formData.confirmCheckBox}
1683
- onChange={(e) =>
1684
- setFormData({
1685
- ...formData,
1686
- confirmCheckBox: e.target.checked,
1687
- })
1688
- }
1689
- disabled={isPopup && !isPopupFormEnabled}
1690
- />
1691
- <div
1692
- style={{
1693
- ...pcStyles.flex,
1694
- ...pcStyles.flexRow,
1695
- ...pcStyles.justifyEnd,
1696
- gap: getVar('--pc-spacing-sm'),
1697
- ...(isPopup ? {
1698
- position: 'sticky',
1699
- bottom: 0,
1700
- left: 0,
1701
- right: 0,
1702
- zIndex: 20,
1703
- borderTop: `1px solid ${getVar('--pc-border-color')}`,
1704
- backgroundColor: getVar('--pc-bg-primary'),
1705
- padding: '0.75rem',
1706
- marginLeft: '-0.75rem',
1707
- marginRight: '-0.75rem',
1708
- } : {
1709
- marginTop: pcStyles.mtMd.marginTop,
1710
- }),
1711
- }}
1712
- className="pc-flex-responsive pc-spacing-responsive"
1713
- >
1714
- {isPopup && (
1715
- <PCButton variant="secondary" onClick={onClose} style={{ flexShrink: 0 }}>
1716
- {t('cancel') ?? 'Cancel'}
1717
- </PCButton>
1718
- )}
1719
- <PCButton
1720
- variant="primary"
1721
- onClick={handleSubmitClick}
1722
- disabled={!isFormValid || isLoading}
1723
- isLoading={isLoading}
1724
- style={{
1725
- ...pcStyles.flex,
1726
- ...pcStyles.itemsCenter,
1727
- ...pcStyles.justifyCenter,
1728
- minWidth: '120px',
1729
- flexShrink: 0,
1730
- opacity: (!isFormValid || isLoading) ? 0.5 : 1,
1731
- cursor: (!isFormValid || isLoading) ? 'not-allowed' : (isLoading ? 'wait' : 'pointer'),
1732
- }}
1733
- >
1734
- {t('submit')}
1735
- </PCButton>
1736
- </div>
1737
- </div>
1738
- </FormBlockWrapper>
1739
- </div>
1740
- </div>
1741
- </div>
1742
- )}
1743
- <PCModal
1744
- isOpen={isErasureConsentModalOpen}
1745
- onClose={handleErasureModalClose}
1746
- size="md"
1747
- showCloseButton={false}
1748
- footer={
1749
- <div className="pc-erasure-modal-footer">
1750
- <PCButton
1751
- variant="secondary"
1752
- onClick={() => handleErasureModalChoice(false)}
1753
- disabled={isLoading}
1754
- >
1755
- {t('revokeConsentNo')}
1756
- </PCButton>
1757
- <PCButton
1758
- variant="primary"
1759
- onClick={() => handleErasureModalChoice(true)}
1760
- disabled={isLoading}
1761
- isLoading={isLoading}
1762
- >
1763
- {t('revokeConsentYes')}
1764
- </PCButton>
1765
- </div>
1766
- }
1767
- >
1768
- <div className="pc-erasure-modal-content">
1769
- <div className="pc-erasure-modal-header">
1770
- <div className="pc-erasure-modal-icon">
1771
- <FiAlertCircle size={24} />
1772
- </div>
1773
- <p className="pc-erasure-modal-title">
1774
- {t('revokeConsentModalTitle')}
1775
- </p>
1776
- <p className="pc-erasure-modal-question">
1777
- {t('revokeConsentModalQuestion')}
1778
- </p>
1779
- </div>
1780
-
1781
- <div className="pc-erasure-modal-purposes">
1782
- <p className="pc-erasure-modal-purposes-label">
1783
- {t('revokeConsentModalPurposesLabel')}
1784
- </p>
1785
- {selectedErasurePurposeList.length > 0 ? (
1786
- <>
1787
- {selectedErasurePurposeList
1788
- .slice(0, MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL)
1789
- .map((name) => (
1790
- <div key={name} className="pc-erasure-modal-purpose-item">
1791
- {name}
1792
- </div>
1793
- ))}
1794
- {selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL && (
1795
- <span className="pc-erasure-modal-purposes-placeholder">
1796
- {t('revokeConsentModalAndMore', {
1797
- count: selectedErasurePurposeList.length - MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL,
1798
- })}
1799
- </span>
1800
- )}
1801
- </>
1802
- ) : (
1803
- <span className="pc-erasure-modal-purposes-placeholder">
1804
- {t('revokeConsentModalYourPurposes')}
1805
- </span>
1806
- )}
1807
- </div>
1808
-
1809
- <div className="pc-erasure-modal-warning">
1810
- <p className="pc-erasure-modal-warning-text">
1811
- <span className="pc-erasure-modal-warning-label">
1812
- {t('revokeConsentModalIfYesLabel')}
1813
- </span>{' '}
1814
- {t('revokeConsentModalIfYesBody')}
1815
- </p>
1816
- </div>
1817
- </div>
1818
- </PCModal>
1819
- </>
1820
- );
1821
- }