@redacto.io/consent-sdk-react 3.5.0 → 3.6.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 (152) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +176 -0
  3. package/dist/index.d.mts +51 -4
  4. package/dist/index.d.ts +51 -4
  5. package/dist/index.js +1891 -243
  6. package/dist/index.mjs +1890 -243
  7. package/dist/privacy-center.d.mts +566 -0
  8. package/dist/privacy-center.d.ts +566 -0
  9. package/dist/privacy-center.js +17174 -0
  10. package/dist/privacy-center.mjs +17136 -0
  11. package/package.json +47 -3
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +201 -118
  13. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1194 -93
  14. package/src/RedactoNoticeConsent/api/index.ts +335 -206
  15. package/src/RedactoNoticeConsent/api/types.ts +140 -16
  16. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +602 -0
  17. package/src/RedactoNoticeConsent/components/index.ts +7 -0
  18. package/src/RedactoNoticeConsent/injectStyles.ts +11 -0
  19. package/src/RedactoNoticeConsent/language-codes.ts +61 -0
  20. package/src/RedactoNoticeConsent/services/storage.ts +122 -0
  21. package/src/RedactoNoticeConsent/types.ts +2 -0
  22. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +19 -0
  23. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +17 -0
  24. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +79 -0
  25. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +27 -0
  26. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +142 -0
  27. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +51 -0
  28. package/src/RedactoPrivacyCenter/api/actions.ts +444 -0
  29. package/src/RedactoPrivacyCenter/api/client.ts +413 -0
  30. package/src/RedactoPrivacyCenter/api/config.ts +33 -0
  31. package/src/RedactoPrivacyCenter/api/fetcher.ts +181 -0
  32. package/src/RedactoPrivacyCenter/api/upload.ts +93 -0
  33. package/src/RedactoPrivacyCenter/assets/character.svg +31 -0
  34. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +9 -0
  35. package/src/RedactoPrivacyCenter/assets/greenTick.svg +4 -0
  36. package/src/RedactoPrivacyCenter/assets/mail.svg +3 -0
  37. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +9 -0
  38. package/src/RedactoPrivacyCenter/assets/sentImg.svg +9 -0
  39. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +9 -0
  40. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +272 -0
  41. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +468 -0
  42. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +433 -0
  43. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +726 -0
  44. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +239 -0
  45. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +7 -0
  46. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +155 -0
  47. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +189 -0
  48. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +118 -0
  49. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +66 -0
  50. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +305 -0
  51. package/src/RedactoPrivacyCenter/components/Form.tsx +1455 -0
  52. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +176 -0
  53. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +330 -0
  54. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +329 -0
  55. package/src/RedactoPrivacyCenter/components/Pagination.tsx +140 -0
  56. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +302 -0
  57. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +218 -0
  58. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +475 -0
  59. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +412 -0
  60. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +42 -0
  61. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +1 -0
  62. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +40 -0
  63. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +38 -0
  64. package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +85 -0
  65. package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +141 -0
  66. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +179 -0
  67. package/src/RedactoPrivacyCenter/components/constant.ts +20 -0
  68. package/src/RedactoPrivacyCenter/components/types.ts +68 -0
  69. package/src/RedactoPrivacyCenter/components/utils.tsx +10 -0
  70. package/src/RedactoPrivacyCenter/config/routes.ts +9 -0
  71. package/src/RedactoPrivacyCenter/config/table.ts +25 -0
  72. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +123 -0
  73. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +44 -0
  74. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +11 -0
  75. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +64 -0
  76. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +30 -0
  77. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +60 -0
  78. package/src/RedactoPrivacyCenter/context/theme.tsx +127 -0
  79. package/src/RedactoPrivacyCenter/lib/api.ts +468 -0
  80. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +63 -0
  81. package/src/RedactoPrivacyCenter/lib/constants.ts +116 -0
  82. package/src/RedactoPrivacyCenter/lib/i18n.ts +81 -0
  83. package/src/RedactoPrivacyCenter/lib/locales.ts +105 -0
  84. package/src/RedactoPrivacyCenter/lib/queries.ts +30 -0
  85. package/src/RedactoPrivacyCenter/lib/types.ts +346 -0
  86. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +37 -0
  87. package/src/RedactoPrivacyCenter/lib/utils.ts +625 -0
  88. package/src/RedactoPrivacyCenter/locales/as/translation.json +188 -0
  89. package/src/RedactoPrivacyCenter/locales/bho/translation.json +188 -0
  90. package/src/RedactoPrivacyCenter/locales/bn/translation.json +188 -0
  91. package/src/RedactoPrivacyCenter/locales/brx/translation.json +188 -0
  92. package/src/RedactoPrivacyCenter/locales/doi/translation.json +188 -0
  93. package/src/RedactoPrivacyCenter/locales/en/translation.json +188 -0
  94. package/src/RedactoPrivacyCenter/locales/gom/translation.json +188 -0
  95. package/src/RedactoPrivacyCenter/locales/gu/translation.json +188 -0
  96. package/src/RedactoPrivacyCenter/locales/hi/translation.json +188 -0
  97. package/src/RedactoPrivacyCenter/locales/kn/translation.json +188 -0
  98. package/src/RedactoPrivacyCenter/locales/ks/translation.json +188 -0
  99. package/src/RedactoPrivacyCenter/locales/mai/translation.json +188 -0
  100. package/src/RedactoPrivacyCenter/locales/ml/translation.json +188 -0
  101. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +188 -0
  102. package/src/RedactoPrivacyCenter/locales/mr/translation.json +188 -0
  103. package/src/RedactoPrivacyCenter/locales/ne/translation.json +188 -0
  104. package/src/RedactoPrivacyCenter/locales/or/translation.json +188 -0
  105. package/src/RedactoPrivacyCenter/locales/pa/translation.json +188 -0
  106. package/src/RedactoPrivacyCenter/locales/sa/translation.json +188 -0
  107. package/src/RedactoPrivacyCenter/locales/sat/translation.json +188 -0
  108. package/src/RedactoPrivacyCenter/locales/sd/translation.json +188 -0
  109. package/src/RedactoPrivacyCenter/locales/ta/translation.json +188 -0
  110. package/src/RedactoPrivacyCenter/locales/te/translation.json +188 -0
  111. package/src/RedactoPrivacyCenter/locales/ur/translation.json +188 -0
  112. package/src/RedactoPrivacyCenter/styles/base.css +117 -0
  113. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1000 -0
  114. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +819 -0
  115. package/src/RedactoPrivacyCenter/styles/responsive.ts +138 -0
  116. package/src/RedactoPrivacyCenter/styles/variables.css +133 -0
  117. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +365 -0
  118. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +479 -0
  119. package/src/RedactoPrivacyCenter/table/index.ts +2 -0
  120. package/src/RedactoPrivacyCenter/types/images.d.ts +29 -0
  121. package/src/RedactoPrivacyCenter/ui/Footer.tsx +73 -0
  122. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +54 -0
  123. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +87 -0
  124. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +47 -0
  125. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +69 -0
  126. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +48 -0
  127. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +77 -0
  128. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +55 -0
  129. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +97 -0
  130. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +35 -0
  131. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +128 -0
  132. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +30 -0
  133. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +104 -0
  134. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +175 -0
  135. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +381 -0
  136. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +137 -0
  137. package/src/RedactoPrivacyCenter/ui/PCText.tsx +39 -0
  138. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +43 -0
  139. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +37 -0
  140. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +119 -0
  141. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +25 -0
  142. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +55 -0
  143. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +70 -0
  144. package/src/RedactoPrivacyCenter/ui/index.ts +18 -0
  145. package/src/RedactoPrivacyCenter/utils/cn.ts +28 -0
  146. package/src/RedactoPrivacyCenter/utils/date.ts +48 -0
  147. package/src/RedactoPrivacyCenter/utils/status.ts +111 -0
  148. package/src/RedactoPrivacyCenter/utils/toast.ts +130 -0
  149. package/src/index.ts +10 -0
  150. package/src/privacy-center.ts +107 -0
  151. package/tsconfig.json +2 -0
  152. package/tsup.config.ts +16 -1
@@ -0,0 +1,1455 @@
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, PCTextarea } from '../ui';
12
+ import { pcStyles } from '../styles/pcStyles';
13
+ import { createCaseRequest, getFormData } from '../lib/api';
14
+ import { ensureValidToken } from '../lib/utils';
15
+ import { useAuth } from '../context/AuthContext';
16
+ import { useBaseUrl } from '../context/BaseUrlContext';
17
+ import {
18
+ useRequestTypeLabels,
19
+ useTimePeriodOptions,
20
+ } from '../lib/constants';
21
+ import { useOrganization } from '../context/OrganizationContext';
22
+ import i18n from '../lib/i18n';
23
+ import { getLangParam } from '../lib/useCurrentLanguage';
24
+ import type {
25
+ DataElement,
26
+ FormData,
27
+ GrievanceOptions,
28
+ GrievanceTypeElement,
29
+ GrievanceValue,
30
+ NominationData,
31
+ Purpose,
32
+ UpdateCorrectionDataItem,
33
+ UpdatePurpose,
34
+ UserDataRequest,
35
+ } from '../lib/types';
36
+ import { RequestTypeEnum } from '../lib/types';
37
+ import CorrectionDataSection from './DataRequest/CorrectionDataSection';
38
+ import GrievanceDataSection from './DataRequest/GrievanceDataSection';
39
+ import NominationDataSection from './DataRequest/NominationDataSection';
40
+ import PurposeSelectionSection from './DataRequest/PurposeSelectionSection';
41
+ import RequestSubmit from './RequestSubmit';
42
+ import type { CaseHistoryProps } from './types';
43
+ import { cn } from '../utils/cn';
44
+ import { FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
45
+
46
+ const getVar = (varName: string): string => `var(${varName})`;
47
+
48
+ export default function Form({
49
+ onCreateRequest,
50
+ isPopup = false,
51
+ onClose,
52
+ }: CaseHistoryProps) {
53
+ const queryClient = useQueryClient();
54
+ const timePeriodOptions = useTimePeriodOptions();
55
+ const { organization } = useOrganization();
56
+ const orgSlug = organization?.slug || organization?.name || '';
57
+
58
+ // Detect if we're in case management context (admin side)
59
+ // Case management uses session auth, not JWT Bearer
60
+ // We rely on an explicit pathname check rather than absence of access_token
61
+ // to avoid incorrectly flagging unauthenticated privacy-center users as case-management
62
+ const isCaseManagementContext =
63
+ typeof window !== 'undefined' &&
64
+ window.location.pathname.includes('/case-management');
65
+ const [requestType, setRequestType] = useState<RequestTypeEnum | null>(null);
66
+ const [timePeriod, setTimePeriod] = useState('');
67
+ const [isSubmitted, setIsSubmitted] = useState(false);
68
+ const { t } = useTranslation();
69
+ const { accessToken, refreshToken, onError, updateTokens } = useAuth();
70
+ const { consentClient, baseUrl } = useBaseUrl();
71
+ const organizationId = organization?.uuid;
72
+ const workspaceId = organization?.workspaces[0]?.uuid;
73
+
74
+ // Create auth context object for API calls - memoized to prevent infinite loops
75
+ const authContext = useMemo(() => ({
76
+ accessToken,
77
+ refreshToken,
78
+ onError,
79
+ updateTokens,
80
+ }), [accessToken, refreshToken, onError, updateTokens]);
81
+ const [data, setData] = useState<FormData | null>(null);
82
+ const [formData, setFormData] = useState({
83
+ uuid: '',
84
+ case_id: '',
85
+ name: '',
86
+ contact: '',
87
+ requestType: '',
88
+ requestDetails: '',
89
+ supportingDocumentation: '',
90
+ additionalNote: '',
91
+ confirmCheckBox: false,
92
+ });
93
+ const [erasurePurposes, setErasurePurposes] = useState<Purpose[]>([]);
94
+ const [accessPurposes, setAccessPurposes] = useState<Purpose[]>([]);
95
+ const [selectedCorrectionElements, setSelectedCorrectionElements] = useState<
96
+ string[]
97
+ >([]);
98
+ const [uniqueDataElements, setUniqueDataElements] = useState<DataElement[]>(
99
+ []
100
+ );
101
+ const [correctionValues, setCorrectionValues] = useState<
102
+ Record<string, { current: string; updated: string }>
103
+ >({});
104
+ const [caseId, setCaseId] = useState<string | null>(null);
105
+ const fileInputRef = useRef<HTMLInputElement>(null);
106
+ // Store the original English form data for submission — translations are display-only
107
+ const englishDataRef = useRef<FormData | null>(null);
108
+ // Store selections to restore after translation
109
+ const selectionsToRestoreRef = useRef<{
110
+ access: Map<string, { purposeSelected: boolean; dataElementSelections: Map<string, boolean> }>;
111
+ erasure: Map<string, { purposeSelected: boolean; dataElementSelections: Map<string, boolean> }>;
112
+ } | null>(null);
113
+ const [uploadedDocumentId, setUploadedDocumentId] = useState<string[]>([]);
114
+ const [uploadedDocuments, setUploadedDocuments] = useState<
115
+ { uuid: string; name: string }[]
116
+ >([]);
117
+ const [isDocumentUploading, setIsDocumentUploading] = useState(false);
118
+ const [isDocument, setIsDocument] = useState(false);
119
+ const [isPopupFormEnabled, setIsPopupFormEnabled] = useState(!isPopup);
120
+ const [isVerifyingContact, setIsVerifyingContact] = useState(false);
121
+ const requestTypeLabels = useRequestTypeLabels();
122
+ const [grievanceOptions, setGrievanceOptions] = useState<GrievanceOptions[]>(
123
+ []
124
+ );
125
+ const [selectedGrievances, setSelectedGrievances] = useState<
126
+ Record<string, boolean>
127
+ >({});
128
+ const [nominationData, setNominationData] = useState<NominationData>({
129
+ nominee_email: '',
130
+ nominee_mobile: '',
131
+ });
132
+ const [isLoading, setIsLoading] = useState(false);
133
+ const [isTranslating, setIsTranslating] = useState(false);
134
+ const [isRequestDetailsExpanded, setIsRequestDetailsExpanded] =
135
+ useState(true);
136
+
137
+ const resetFileInput = () => {
138
+ if (fileInputRef.current) {
139
+ fileInputRef.current.value = '';
140
+ }
141
+ };
142
+
143
+ useEffect(() => {
144
+ if (data?.purposes) {
145
+ // Validate purposes array structure
146
+ if (!Array.isArray(data.purposes)) {
147
+ console.warn('Form data purposes is not an array:', data.purposes);
148
+ return;
149
+ }
150
+
151
+ const extractedPurposes = data.purposes
152
+ .map((p) => p?.purpose)
153
+ .filter((p) => p !== null && p !== undefined);
154
+
155
+ if (extractedPurposes.length === 0) {
156
+ console.warn('No valid purposes found in form data');
157
+ return;
158
+ }
159
+
160
+ const uniqueElements = new Map();
161
+ extractedPurposes.forEach((purpose) => {
162
+ // Ensure data_elements is an array and filter out any null/undefined elements
163
+ // Only include data elements with selected === true
164
+ const validDataElements = Array.isArray(purpose.data_elements)
165
+ ? purpose.data_elements.filter(
166
+ (element) => element !== null && element !== undefined && element.uuid && element.selected
167
+ )
168
+ : [];
169
+
170
+ // Data elements are validated and processed
171
+
172
+ validDataElements.forEach((element) => {
173
+ if (element.uuid && !uniqueElements.has(element.uuid)) {
174
+ uniqueElements.set(element.uuid, {
175
+ ...element,
176
+ purposeName: purpose.name,
177
+ });
178
+ }
179
+ });
180
+ });
181
+
182
+ setUniqueDataElements(
183
+ Array.from(uniqueElements.values()) as DataElement[]
184
+ );
185
+
186
+ // Ensure data_elements are properly preserved with all properties
187
+ // Only include data elements with selected === true, and filter out purposes with no data elements
188
+ const processedPurposes = extractedPurposes
189
+ .map((p) => {
190
+ const validDataElements = Array.isArray(p.data_elements)
191
+ ? p.data_elements
192
+ .filter((de) => de !== null && de !== undefined && de.selected)
193
+ .map((de) => ({
194
+ ...de,
195
+ selected: false,
196
+ }))
197
+ : [];
198
+
199
+ return {
200
+ ...p,
201
+ selected: false,
202
+ data_elements: validDataElements,
203
+ };
204
+ })
205
+ .filter((p) => p.data_elements.length > 0);
206
+
207
+ setAccessPurposes(processedPurposes);
208
+ setErasurePurposes(processedPurposes);
209
+ }
210
+ }, [data]);
211
+
212
+ const fetchAndApplyTranslations = useCallback(async (langValue: string) => {
213
+ if (!organizationId || !workspaceId || !data) {
214
+ return;
215
+ }
216
+
217
+ const contact = formData.contact ||
218
+ (typeof window !== 'undefined' ? sessionStorage.getItem('email') || '' : '');
219
+
220
+ if (!contact) {
221
+ return;
222
+ }
223
+
224
+ setIsTranslating(true);
225
+
226
+ try {
227
+ const translatedFormData = await getFormData(
228
+ organizationId,
229
+ workspaceId,
230
+ contact,
231
+ isPopup,
232
+ isCaseManagementContext,
233
+ authContext,
234
+ consentClient,
235
+ langValue
236
+ );
237
+
238
+ if (!translatedFormData) {
239
+ // Fallback to English when translation API unavailable
240
+ toast.info(t('translationNotAvailable'));
241
+ return;
242
+ }
243
+
244
+ const currentAccessSelections = new Map<string, {
245
+ purposeSelected: boolean;
246
+ dataElementSelections: Map<string, boolean>;
247
+ }>();
248
+ const currentErasureSelections = new Map<string, {
249
+ purposeSelected: boolean;
250
+ dataElementSelections: Map<string, boolean>;
251
+ }>();
252
+
253
+ accessPurposes.forEach((purpose) => {
254
+ const dataElementSelections = new Map<string, boolean>();
255
+ purpose.data_elements.forEach((de) => {
256
+ dataElementSelections.set(de.uuid, de.selected);
257
+ });
258
+ currentAccessSelections.set(purpose.uuid, {
259
+ purposeSelected: purpose.selected,
260
+ dataElementSelections,
261
+ });
262
+ });
263
+
264
+ erasurePurposes.forEach((purpose) => {
265
+ const dataElementSelections = new Map<string, boolean>();
266
+ purpose.data_elements.forEach((de) => {
267
+ dataElementSelections.set(de.uuid, de.selected);
268
+ });
269
+ currentErasureSelections.set(purpose.uuid, {
270
+ purposeSelected: purpose.selected,
271
+ dataElementSelections,
272
+ });
273
+ });
274
+
275
+ if (!translatedFormData.purposes ||
276
+ !Array.isArray(translatedFormData.purposes) ||
277
+ translatedFormData.purposes.length === 0) {
278
+ // Fallback to English when no translations for this language
279
+ toast.info(t('translationNotAvailable'));
280
+ return;
281
+ }
282
+
283
+ setData(translatedFormData);
284
+
285
+ if (translatedFormData.grievance_options) {
286
+ const currentGrievanceSelections = { ...selectedGrievances };
287
+ setGrievanceOptions(translatedFormData.grievance_options);
288
+ const updatedGrievanceSelections: Record<string, boolean> = {};
289
+ translatedFormData.grievance_options.forEach((option) => {
290
+ updatedGrievanceSelections[option.value] =
291
+ currentGrievanceSelections[option.value] || false;
292
+ });
293
+ setSelectedGrievances(updatedGrievanceSelections);
294
+ }
295
+
296
+ selectionsToRestoreRef.current = {
297
+ access: currentAccessSelections,
298
+ erasure: currentErasureSelections,
299
+ };
300
+ } catch (error) {
301
+ console.error('Error fetching translation:', error);
302
+ // Fallback to English when translation API fails
303
+ toast.info(t('translationNotAvailable'));
304
+ } finally {
305
+ setIsTranslating(false);
306
+ }
307
+ }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, accessPurposes, erasurePurposes, selectedGrievances, t]);
308
+
309
+ // Clear previous selections when request type changes
310
+ useEffect(() => {
311
+ setSelectedCorrectionElements([]);
312
+ setCorrectionValues({});
313
+ setNominationData({ nominee_email: '', nominee_mobile: '' });
314
+ if (data?.purposes && Array.isArray(data.purposes)) {
315
+ const extractedPurposes = data.purposes
316
+ .map((p) => p?.purpose)
317
+ .filter((p) => p !== null && p !== undefined);
318
+
319
+ // Ensure data_elements are properly preserved with all properties
320
+ // Only include data elements with selected === true, and filter out purposes with no data elements
321
+ const processedPurposes = extractedPurposes
322
+ .map((p) => {
323
+ const validDataElements = Array.isArray(p.data_elements)
324
+ ? p.data_elements
325
+ .filter((de) => de !== null && de !== undefined && de.selected)
326
+ .map((de) => ({
327
+ ...de,
328
+ selected: false,
329
+ }))
330
+ : [];
331
+
332
+ return {
333
+ ...p,
334
+ selected: false,
335
+ data_elements: validDataElements,
336
+ };
337
+ })
338
+ .filter((p) => p.data_elements.length > 0);
339
+
340
+ setAccessPurposes(processedPurposes);
341
+ setErasurePurposes(processedPurposes);
342
+
343
+ if (
344
+ requestType === RequestTypeEnum.GRIEVANCE &&
345
+ grievanceOptions.length > 0
346
+ ) {
347
+ const resetGrievances = grievanceOptions.reduce(
348
+ (acc, option) => {
349
+ acc[option.value] = false;
350
+ return acc;
351
+ },
352
+ {} as Record<string, boolean>
353
+ );
354
+ setSelectedGrievances(resetGrievances);
355
+ }
356
+ }
357
+ }, [requestType, data, grievanceOptions]);
358
+
359
+
360
+ // Fetch API translations when language changes (purposes, data elements, grievance options)
361
+ useEffect(() => {
362
+ const handleLanguageChanged = () => {
363
+ const lang = i18n.language;
364
+ if (organizationId && workspaceId && data) {
365
+ const contact = formData.contact ||
366
+ (typeof window !== 'undefined' ? sessionStorage.getItem('email') || '' : '');
367
+ if (contact) {
368
+ fetchAndApplyTranslations(lang);
369
+ }
370
+ }
371
+ };
372
+
373
+ i18n.on('languageChanged', handleLanguageChanged);
374
+
375
+ return () => {
376
+ i18n.off('languageChanged', handleLanguageChanged);
377
+ };
378
+ }, [organizationId, workspaceId, data, formData.contact, fetchAndApplyTranslations]);
379
+
380
+ useEffect(() => {
381
+ const initializePrivacyCenterForm = async () => {
382
+ if (isPopup) {
383
+ return;
384
+ }
385
+
386
+ if (!organizationId || !workspaceId) {
387
+ onError?.(new Error('Organization or workspace not found'));
388
+ return;
389
+ }
390
+
391
+ const storedContact =
392
+ typeof window !== 'undefined'
393
+ ? sessionStorage.getItem('email') || ''
394
+ : '';
395
+
396
+ if (!storedContact) {
397
+ onError?.(new Error('Contact email not found'));
398
+ return;
399
+ }
400
+
401
+ try {
402
+ // Always fetch English data first and store for submission
403
+ const englishData = await getFormData(
404
+ organizationId,
405
+ workspaceId,
406
+ storedContact,
407
+ isPopup,
408
+ isCaseManagementContext,
409
+ authContext,
410
+ consentClient
411
+ );
412
+
413
+ if (!englishData) {
414
+ toast.error(t('failedToFetchFormData'));
415
+ onError?.(new Error('Failed to fetch form data'));
416
+ return;
417
+ }
418
+
419
+ englishDataRef.current = englishData;
420
+
421
+ // If current language is non-English, fetch translated data for display
422
+ const langParam = getLangParam(i18n.language);
423
+ const displayData = langParam
424
+ ? (await getFormData(
425
+ organizationId,
426
+ workspaceId,
427
+ storedContact,
428
+ isPopup,
429
+ isCaseManagementContext,
430
+ authContext,
431
+ consentClient,
432
+ langParam
433
+ )) || englishData
434
+ : englishData;
435
+
436
+ if (displayData.purposes && Array.isArray(displayData.purposes)) {
437
+ const purposesWithDataElements = displayData.purposes.filter(
438
+ (p) => p.purpose?.data_elements && p.purpose.data_elements.length > 0
439
+ );
440
+
441
+ if (purposesWithDataElements.length === 0) {
442
+ console.warn('No purposes with data elements found in API response');
443
+ }
444
+ }
445
+
446
+ setData(displayData);
447
+ setGrievanceOptions(displayData.grievance_options || []);
448
+ const initialGrievances = (
449
+ displayData.grievance_options || []
450
+ ).reduce(
451
+ (acc, option) => {
452
+ acc[option.value] = false;
453
+ return acc;
454
+ },
455
+ {} as Record<string, boolean>
456
+ );
457
+ setSelectedGrievances(initialGrievances);
458
+ setFormData((prev) => ({
459
+ ...prev,
460
+ uuid: displayData.uuid || '',
461
+ name: displayData.name || '',
462
+ contact: displayData.contact || storedContact,
463
+ }));
464
+ } finally {
465
+ }
466
+ };
467
+
468
+ void initializePrivacyCenterForm();
469
+ }, [organizationId, workspaceId, isPopup, authContext, consentClient]);
470
+
471
+ /**
472
+ * Maps translated correction element names back to English names using UUID lookup.
473
+ */
474
+ const getEnglishDataElementName = useCallback((translatedName: string): string => {
475
+ if (!englishDataRef.current?.purposes) return translatedName;
476
+
477
+ // Build a map from translated name -> english name via UUID
478
+ const translatedToUuid = new Map<string, string>();
479
+ if (data?.purposes) {
480
+ data.purposes.forEach((pw) => {
481
+ pw.purpose?.data_elements?.forEach((de) => {
482
+ translatedToUuid.set(de.name, de.uuid);
483
+ });
484
+ });
485
+ }
486
+
487
+ const uuid = translatedToUuid.get(translatedName);
488
+ if (!uuid) return translatedName;
489
+
490
+ for (const pw of englishDataRef.current.purposes) {
491
+ const englishDe = pw.purpose?.data_elements?.find((de) => de.uuid === uuid);
492
+ if (englishDe) return englishDe.name;
493
+ }
494
+ return translatedName;
495
+ }, [data]);
496
+
497
+ const handleSubmit = async () => {
498
+ if (!isFormValid) return;
499
+
500
+ const requestDetails: {
501
+ purposes: UpdatePurpose[];
502
+ correction_data: UpdateCorrectionDataItem[];
503
+ grievance_types: GrievanceTypeElement[];
504
+ nomination_data?: NominationData;
505
+ } = {
506
+ purposes: [],
507
+ correction_data: [],
508
+ grievance_types: [],
509
+ };
510
+
511
+ if (requestType === RequestTypeEnum.ACCESS) {
512
+ requestDetails.purposes = accessPurposes
513
+ .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
514
+ .map((p) => ({
515
+ purpose_uuid: p.uuid,
516
+ data_element_uuids: p.data_elements
517
+ .filter((de) => de.selected)
518
+ .map((de) => de.uuid),
519
+ }));
520
+ }
521
+
522
+ if (requestType === RequestTypeEnum.ERASURE) {
523
+ requestDetails.purposes = erasurePurposes
524
+ .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
525
+ .map((p) => ({
526
+ purpose_uuid: p.uuid,
527
+ data_element_uuids: p.data_elements
528
+ .filter((de) => de.selected)
529
+ .map((de) => de.uuid),
530
+ }));
531
+ }
532
+
533
+ if (requestType === RequestTypeEnum.CORRECTION) {
534
+ requestDetails.correction_data = selectedCorrectionElements.map(
535
+ (name) => ({
536
+ name: getEnglishDataElementName(name),
537
+ curr_value: correctionValues[name]?.current || '',
538
+ new_value: correctionValues[name]?.updated || '',
539
+ })
540
+ );
541
+ }
542
+
543
+ if (requestType === RequestTypeEnum.GRIEVANCE) {
544
+ requestDetails.grievance_types = Object.entries(selectedGrievances)
545
+ .filter(([, checked]) => checked)
546
+ .map(([key]) => ({
547
+ grievance: key as GrievanceValue,
548
+ }));
549
+ }
550
+
551
+ if (requestType === RequestTypeEnum.NOMINATION) {
552
+ requestDetails.nomination_data = {
553
+ nominee_email: nominationData.nominee_email.trim(),
554
+ ...(nominationData.nominee_mobile?.trim()
555
+ ? { nominee_mobile: nominationData.nominee_mobile.trim() }
556
+ : {}),
557
+ };
558
+ }
559
+
560
+ const payload: UserDataRequest = {
561
+ name: ' ',
562
+ contact: formData.contact,
563
+ request_type: requestType?.toLowerCase() as RequestTypeEnum,
564
+ uuid: formData.uuid,
565
+ supporting_docs_uuids: [...uploadedDocumentId],
566
+ request_details: requestDetails,
567
+ time_period:
568
+ requestType === RequestTypeEnum.ACCESS
569
+ ? parseInt(timePeriod || '30', 10)
570
+ : 0,
571
+ requestor_note: formData.additionalNote || '',
572
+ };
573
+
574
+ try {
575
+ setIsLoading(true);
576
+ const response = await createCaseRequest(
577
+ organizationId || '',
578
+ workspaceId || '',
579
+ payload,
580
+ isPopup,
581
+ isCaseManagementContext, // Use session auth if in case management context
582
+ authContext,
583
+ consentClient
584
+ );
585
+ // privacyCenterApiFetch unwraps { code, status, detail } responses and returns just the detail
586
+ // So response will be { uuid, case_id } if successful (privacy center), or null if error
587
+ // Case management returns the full CreateCaseResponse with { code, status, detail: { uuid, case_id } }
588
+ let caseId: string | null = null;
589
+ if (response) {
590
+ if ('detail' in response && response.detail) {
591
+ // Case management: wrapped response
592
+ caseId =
593
+ (response.detail as { case_id?: string; uuid?: string }).case_id ||
594
+ (response.detail as { case_id?: string; uuid?: string }).uuid ||
595
+ null;
596
+ } else if ('case_id' in response || 'uuid' in response) {
597
+ // Privacy center: unwrapped response
598
+ caseId =
599
+ (response as { case_id?: string; uuid?: string }).case_id ||
600
+ (response as { case_id?: string; uuid?: string }).uuid ||
601
+ null;
602
+ }
603
+ }
604
+
605
+ if (caseId) {
606
+ queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
607
+ setCaseId(caseId);
608
+ setIsSubmitted(true);
609
+ toast.success(t('requestSubmittedSuccess'));
610
+
611
+ if (isPopup) {
612
+ onCreateRequest?.();
613
+ }
614
+ } else {
615
+ toast.error(t('requestSubmissionFailed'));
616
+ }
617
+ } catch (error) {
618
+ console.error('Error submitting request:', error);
619
+ toast.error(t('submissionFailed'));
620
+ } finally {
621
+ setIsLoading(false);
622
+ if (isPopup) {
623
+ onClose?.();
624
+ }
625
+ }
626
+ };
627
+
628
+ const isRequestDetailsValid = useCallback(() => {
629
+ if (!requestType) return false;
630
+ switch (requestType) {
631
+ case RequestTypeEnum.ACCESS:
632
+ return accessPurposes.some(
633
+ (p) => p.selected || p.data_elements.some((de) => de.selected)
634
+ );
635
+
636
+ case RequestTypeEnum.ERASURE:
637
+ return erasurePurposes.some(
638
+ (p) => p.selected || p.data_elements.some((de) => de.selected)
639
+ );
640
+
641
+ case RequestTypeEnum.GRIEVANCE:
642
+ return Object.values(selectedGrievances).some(Boolean);
643
+
644
+ case RequestTypeEnum.CORRECTION:
645
+ return (
646
+ selectedCorrectionElements.length > 0 &&
647
+ selectedCorrectionElements.every(
648
+ (name) =>
649
+ correctionValues[name]?.current?.trim() &&
650
+ correctionValues[name]?.updated?.trim()
651
+ )
652
+ );
653
+
654
+ case RequestTypeEnum.NOMINATION:
655
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nominationData.nominee_email.trim());
656
+
657
+ default:
658
+ return false;
659
+ }
660
+ }, [
661
+ requestType,
662
+ accessPurposes,
663
+ erasurePurposes,
664
+ selectedGrievances,
665
+ selectedCorrectionElements,
666
+ correctionValues,
667
+ nominationData,
668
+ ]);
669
+
670
+ const isFormValid = useMemo(() => {
671
+ return (
672
+ (!isPopup || isPopupFormEnabled) &&
673
+ !!formData.contact?.trim() &&
674
+ !!requestType &&
675
+ isRequestDetailsValid() &&
676
+ formData.confirmCheckBox
677
+ );
678
+ }, [
679
+ isPopup,
680
+ isPopupFormEnabled,
681
+ formData.contact,
682
+ formData.confirmCheckBox,
683
+ requestType,
684
+ isRequestDetailsValid,
685
+ ]);
686
+
687
+ const handleFileUpload = async (
688
+ file: File,
689
+ isPublic: boolean,
690
+ caseId: string
691
+ ) => {
692
+ setIsDocumentUploading(true);
693
+ try {
694
+ const metaData = {
695
+ group_by: 'privacy',
696
+ case_uuid: caseId,
697
+ product: 'privacy_center',
698
+ };
699
+
700
+ const payload = {
701
+ organization_uuid: organizationId || '',
702
+ space: `privacy/${caseId}`,
703
+ uuid: crypto.randomUUID(),
704
+ is_public: isPublic,
705
+ allow_ai_processing: true,
706
+ ...(metaData ? { metadata: metaData } : {}),
707
+ };
708
+
709
+ // Create FormData on client side - this can be passed to server actions
710
+ const formData = new FormData();
711
+ formData.append('file', file);
712
+ formData.append('payload', JSON.stringify(payload));
713
+
714
+ // Ensure we have a valid token before uploading
715
+ if (!organizationId || !workspaceId) {
716
+ throw new Error('Organization ID and Workspace ID are required for file upload');
717
+ }
718
+
719
+ const authToken = await ensureValidToken(organizationId, workspaceId, orgSlug, authContext, consentClient);
720
+ if (!authToken) {
721
+ throw new Error('Authentication token is required for file upload');
722
+ }
723
+
724
+ // uploadFileWithS2S is a client-side function that uses JWT Bearer token authentication
725
+ const uploadedDoc = await uploadFileWithS2S({
726
+ formData,
727
+ fileName: file.name,
728
+ authToken,
729
+ baseUrl,
730
+ });
731
+
732
+ if (!uploadedDoc.success) {
733
+ toast.error(t('failedToUploadFile', { fileName: file.name }));
734
+ throw new Error(uploadedDoc.error);
735
+ }
736
+
737
+ if (uploadedDoc.uuid) {
738
+ setUploadedDocuments((prev) => [
739
+ ...prev,
740
+ { uuid: uploadedDoc.uuid!, name: file.name },
741
+ ]);
742
+ setUploadedDocumentId((prevIds) => [...prevIds, uploadedDoc.uuid!]);
743
+ setIsDocument(true);
744
+ toast.success(t('fileUploadedSuccess'));
745
+ }
746
+ } catch (error) {
747
+ console.error('Upload error:', error);
748
+ toast.error(t('uploadError', { error: String(error) }));
749
+ return null;
750
+ } finally {
751
+ setIsDocumentUploading(false);
752
+ }
753
+ };
754
+
755
+ const handleFileChange = async (
756
+ event: React.ChangeEvent<HTMLInputElement>
757
+ ) => {
758
+ const files = Array.from(event.target.files || []);
759
+ handleFileUpload(files[0], true, formData.uuid);
760
+ resetFileInput();
761
+ };
762
+
763
+ const handleRemoveDocument = (uuidToRemove: string) => {
764
+ setUploadedDocuments((prev) =>
765
+ prev.filter((doc) => doc.uuid !== uuidToRemove)
766
+ );
767
+ setUploadedDocumentId((prev) => prev.filter((id) => id !== uuidToRemove));
768
+ };
769
+
770
+ const handleVerifyContact = async () => {
771
+ if (!isPopup) return;
772
+ if (!formData.contact) {
773
+ toast.error(t('pleaseEnterContactToVerify'));
774
+ return;
775
+ }
776
+ if (!organizationId || !workspaceId) {
777
+ toast.error(t('missingOrgOrWorkspace'));
778
+ return;
779
+ }
780
+
781
+ setIsVerifyingContact(true);
782
+ try {
783
+ // Always fetch English data first and store for submission
784
+ const englishData = await getFormData(
785
+ organizationId,
786
+ workspaceId,
787
+ formData.contact,
788
+ isPopup,
789
+ isCaseManagementContext,
790
+ authContext,
791
+ consentClient
792
+ );
793
+
794
+ if (englishData) {
795
+ englishDataRef.current = englishData;
796
+
797
+ // If current language is non-English, fetch translated data for display
798
+ const langParam = getLangParam(i18n.language);
799
+ const displayData = langParam
800
+ ? (await getFormData(
801
+ organizationId,
802
+ workspaceId,
803
+ formData.contact,
804
+ isPopup,
805
+ isCaseManagementContext,
806
+ authContext,
807
+ consentClient,
808
+ langParam
809
+ )) || englishData
810
+ : englishData;
811
+
812
+ if (displayData.purposes && Array.isArray(displayData.purposes)) {
813
+ const purposesWithDataElements = displayData.purposes.filter(
814
+ (p) => p.purpose?.data_elements && p.purpose.data_elements.length > 0
815
+ );
816
+
817
+ if (purposesWithDataElements.length === 0) {
818
+ console.warn('No purposes with data elements found in API response (popup)');
819
+ }
820
+ }
821
+
822
+ setData(displayData);
823
+ setGrievanceOptions(displayData.grievance_options || []);
824
+ const initialGrievances = (displayData.grievance_options || []).reduce(
825
+ (acc, option) => {
826
+ acc[option.value] = false;
827
+ return acc;
828
+ },
829
+ {} as Record<string, boolean>
830
+ );
831
+ setSelectedGrievances(initialGrievances);
832
+ setFormData((prev) => ({
833
+ ...prev,
834
+ uuid: displayData.uuid || '',
835
+ name: displayData.name || '',
836
+ contact: displayData.contact || prev.contact,
837
+ }));
838
+ // Translations are handled locally via i18next, no API call needed
839
+ setIsPopupFormEnabled(true);
840
+ toast.success(t('contactVerifiedSuccess'));
841
+ } else {
842
+ setIsPopupFormEnabled(false);
843
+ toast.error(t('unableToVerifyContact'));
844
+ }
845
+ } catch (error: any) {
846
+ if (error.response?.status === 404) {
847
+ toast.info(t('noUserFoundForContact', { contact: formData.contact }));
848
+ onClose?.();
849
+ } else {
850
+ toast.error(t('failedToVerifyContact'));
851
+ setIsVerifyingContact(false);
852
+ }
853
+ } finally {
854
+ setIsVerifyingContact(false);
855
+ }
856
+ };
857
+
858
+ return (
859
+ <>
860
+ {isSubmitted && !isPopup ? (
861
+ <RequestSubmit
862
+ caseId={caseId || ''}
863
+ contact={formData?.contact}
864
+ onCreateRequest={onCreateRequest}
865
+ />
866
+ ) : (
867
+ <div>
868
+ <div>
869
+ {!isPopup && (
870
+ <p style={{
871
+ marginTop: 0,
872
+ marginBottom: getVar('--pc-spacing-sm'),
873
+ lineHeight: 1.5,
874
+ fontSize: pcStyles.textSm.fontSize,
875
+ color: getVar('--pc-text-secondary'),
876
+ }}>{t('description')}</p>
877
+ )}
878
+ <div
879
+ style={{
880
+ backgroundColor: getVar('--pc-bg-primary'),
881
+ ...(isPopup ? {
882
+ marginTop: 0,
883
+ marginBottom: 0,
884
+ borderRadius: pcStyles.borderRadiusXl,
885
+ } : {
886
+ marginTop: pcStyles.mtSm.marginTop,
887
+ marginBottom: pcStyles.mbSm.marginBottom,
888
+ borderRadius: pcStyles.borderRadiusXl,
889
+ border: `1px solid ${getVar('--pc-border-color')}`,
890
+ boxShadow: getVar('--pc-shadow-sm'),
891
+ }),
892
+ }}
893
+ >
894
+ <FormBlockWrapper inputClassName="mx-auto">
895
+ <div
896
+ style={{
897
+ ...pcStyles.flex,
898
+ ...pcStyles.itemsCenter,
899
+ ...pcStyles.justifyBetween,
900
+ backgroundColor: `var(--pc-bg-secondary)`,
901
+ padding: getVar('--pc-spacing-md'),
902
+ width: '100%',
903
+ overflow: 'visible',
904
+ ...(isPopup ? {
905
+ position: 'sticky',
906
+ top: 0,
907
+ zIndex: 20,
908
+ borderBottom: `1px solid var(--pc-border-color)`,
909
+ } : {
910
+ position: 'relative',
911
+ borderRadius: `${getVar('--pc-border-radius-lg')} ${getVar('--pc-border-radius-lg')} 0 0`,
912
+ }),
913
+ }}
914
+ className="pc-padding-responsive"
915
+ >
916
+ <p style={{
917
+ fontSize: pcStyles.textSm.fontSize,
918
+ fontWeight: pcStyles.fontWeightSemibold.fontWeight,
919
+ color: `var(--pc-text-primary)`,
920
+ margin: 0,
921
+ whiteSpace: 'nowrap',
922
+ flexShrink: 0,
923
+ flexGrow: 0,
924
+ }} className="pc-text-responsive">
925
+ {t('raiseDataRequest')}
926
+ </p>
927
+ {isPopup && (
928
+ <PCButton
929
+ variant="text"
930
+ onClick={onClose}
931
+ style={{ padding: 0, flexShrink: 0, marginLeft: 'auto' }}
932
+ >
933
+ <FiX size={20} />
934
+ </PCButton>
935
+ )}
936
+ </div>
937
+ <div
938
+ style={{
939
+ ...pcStyles.flex,
940
+ ...pcStyles.flexCol,
941
+ gap: getVar('--pc-spacing-lg'),
942
+ padding: getVar('--pc-spacing-lg'),
943
+ }}
944
+ className="pc-padding-responsive pc-spacing-responsive"
945
+ >
946
+ <div style={{
947
+ ...pcStyles.flex,
948
+ width: '100%',
949
+ ...pcStyles.itemsEnd,
950
+ gap: getVar('--pc-spacing-sm'),
951
+ flexWrap: 'wrap',
952
+ }}>
953
+ <PCInput
954
+ style={{ flex: 1, minWidth: '200px' }}
955
+ label={
956
+ <span>
957
+ {t('contact')}
958
+ <span style={{ color: getVar('--pc-error') }}>*</span>
959
+ </span>
960
+ }
961
+ placeholder={t('contactPlaceholder')}
962
+ value={formData.contact}
963
+ readOnly={!isPopup}
964
+ onChange={(e) => {
965
+ if (!isPopup) return;
966
+ setFormData({
967
+ ...formData,
968
+ contact: e.target.value,
969
+ });
970
+ setIsPopupFormEnabled(false);
971
+ }}
972
+ />
973
+ {isPopup && !isPopupFormEnabled && (
974
+ <div style={{ ...pcStyles.flex, ...pcStyles.justifyEnd }}>
975
+ <PCButton
976
+ size="sm"
977
+ onClick={handleVerifyContact}
978
+ style={{ height: '2.25rem', flexShrink: 0 }}
979
+ disabled={!formData.contact || isVerifyingContact}
980
+ >
981
+ {isVerifyingContact ? t('verifying') : t('verify')}
982
+ </PCButton>
983
+ </div>
984
+ )}
985
+ </div>
986
+ <PCSelect
987
+ label={
988
+ <span>
989
+ {t('requestType')}
990
+ <span style={{ color: getVar('--pc-error') }}>*</span>
991
+ </span>
992
+ }
993
+ options={Object.values(RequestTypeEnum).map((type) => ({
994
+ value: type,
995
+ label: requestTypeLabels[type],
996
+ }))}
997
+ value={
998
+ Object.values(RequestTypeEnum)
999
+ .map((type) => ({
1000
+ value: type,
1001
+ label: requestTypeLabels[type],
1002
+ }))
1003
+ .find((opt) => opt.value === requestType) || null
1004
+ }
1005
+ onChange={(value) => {
1006
+ if (value) {
1007
+ setRequestType(value.value as RequestTypeEnum);
1008
+ setTimePeriod('');
1009
+ setIsRequestDetailsExpanded(true);
1010
+ }
1011
+ }}
1012
+ placeholder={t('selectRequestType')}
1013
+ disabled={isPopup && !isPopupFormEnabled}
1014
+ />
1015
+ <div
1016
+ style={{
1017
+ ...pcStyles.flex,
1018
+ ...pcStyles.flexCol,
1019
+ borderRadius: pcStyles.borderRadiusLg,
1020
+ border: `1px solid ${getVar('--pc-border-color')}`,
1021
+ backgroundColor: getVar('--pc-bg-secondary'),
1022
+ overflow: 'hidden',
1023
+ ...(isPopup && !isPopupFormEnabled ? {
1024
+ pointerEvents: 'none',
1025
+ opacity: 0.6,
1026
+ } : {}),
1027
+ }}
1028
+ >
1029
+ <div style={{
1030
+ ...pcStyles.flex,
1031
+ ...pcStyles.itemsCenter,
1032
+ ...pcStyles.justifyBetween,
1033
+ padding: getVar('--pc-spacing-md'),
1034
+ borderBottom: isRequestDetailsExpanded ? `1px solid ${getVar('--pc-border-color')}` : 'none',
1035
+ }}>
1036
+ <p style={{
1037
+ fontWeight: pcStyles.fontWeightSemibold.fontWeight,
1038
+ color: `var(--pc-text-primary)`,
1039
+ fontSize: pcStyles.textSm.fontSize,
1040
+ margin: 0,
1041
+ }}>
1042
+ {t('requestDetails')}{' '}
1043
+ <span style={{ color: getVar('--pc-error') }}>*</span>
1044
+ </p>
1045
+ <PCButton
1046
+ variant="text"
1047
+ size="sm"
1048
+ style={{ height: 'auto', padding: 0, color: 'var(--pc-text-secondary)', flexShrink: 0 }}
1049
+ onClick={() =>
1050
+ setIsRequestDetailsExpanded(
1051
+ !isRequestDetailsExpanded
1052
+ )
1053
+ }
1054
+ >
1055
+ {isRequestDetailsExpanded ? (
1056
+ <FiChevronUp size={20} />
1057
+ ) : (
1058
+ <FiChevronDown size={20} />
1059
+ )}
1060
+ </PCButton>
1061
+ </div>
1062
+ {isRequestDetailsExpanded && (
1063
+ <div style={{
1064
+ padding: getVar('--pc-spacing-md'),
1065
+ }}>
1066
+ {requestType === RequestTypeEnum.ACCESS ||
1067
+ requestType === RequestTypeEnum.ERASURE ? (
1068
+ <PurposeSelectionSection
1069
+ purposes={
1070
+ requestType === RequestTypeEnum.ACCESS
1071
+ ? accessPurposes
1072
+ : erasurePurposes
1073
+ }
1074
+ setPurposes={
1075
+ requestType === RequestTypeEnum.ACCESS
1076
+ ? setAccessPurposes
1077
+ : setErasurePurposes
1078
+ }
1079
+ requestType={requestType}
1080
+ isTranslating={isTranslating}
1081
+ />
1082
+ ) : requestType === RequestTypeEnum.CORRECTION ? (
1083
+ <CorrectionDataSection
1084
+ dataElements={uniqueDataElements}
1085
+ selected={selectedCorrectionElements}
1086
+ setSelected={setSelectedCorrectionElements}
1087
+ correctionValues={correctionValues}
1088
+ setCorrectionValues={setCorrectionValues}
1089
+ isTranslating={isTranslating}
1090
+ />
1091
+ ) : requestType === RequestTypeEnum.GRIEVANCE ? (
1092
+ <GrievanceDataSection
1093
+ grievanceOptions={grievanceOptions}
1094
+ selectedGrievances={selectedGrievances}
1095
+ setSelectedGrievances={setSelectedGrievances}
1096
+ isTranslating={isTranslating}
1097
+ />
1098
+ ) : requestType === RequestTypeEnum.NOMINATION ? (
1099
+ <NominationDataSection
1100
+ nominationData={nominationData}
1101
+ setNominationData={setNominationData}
1102
+ isTranslating={isTranslating}
1103
+ />
1104
+ ) : (
1105
+ <div
1106
+ style={{
1107
+ color: getVar('--pc-text-secondary'),
1108
+ ...(isPopup ? {
1109
+ padding: getVar('--pc-spacing-md'),
1110
+ } : {
1111
+ ...pcStyles.flex,
1112
+ height: '8rem',
1113
+ ...pcStyles.itemsCenter,
1114
+ ...pcStyles.justifyCenter,
1115
+ padding: getVar('--pc-spacing-lg'),
1116
+ }),
1117
+ textAlign: 'center',
1118
+ }}
1119
+ >
1120
+ {t('selectRequestType')}
1121
+ </div>
1122
+ )}
1123
+ </div>
1124
+ )}
1125
+ </div>
1126
+ <div
1127
+ style={{
1128
+ ...pcStyles.flex,
1129
+ ...pcStyles.flexCol,
1130
+ gap: getVar('--pc-spacing-sm'),
1131
+ ...(isPopup && !isPopupFormEnabled ? {
1132
+ pointerEvents: 'none',
1133
+ opacity: 0.6,
1134
+ } : {}),
1135
+ }}
1136
+ >
1137
+ <p style={{
1138
+ fontWeight: pcStyles.fontWeightSemibold.fontWeight,
1139
+ color: `var(--pc-text-primary)`,
1140
+ fontSize: pcStyles.textSm.fontSize,
1141
+ margin: 0,
1142
+ }}>
1143
+ {t('supportingDocumentation')}
1144
+ </p>
1145
+ <input
1146
+ ref={fileInputRef}
1147
+ id="privacy-file-upload"
1148
+ type="file"
1149
+ onChange={(e) => handleFileChange(e)}
1150
+ style={{ display: 'none' }}
1151
+ />
1152
+ <div
1153
+ style={{
1154
+ ...pcStyles.flex,
1155
+ flexWrap: 'wrap',
1156
+ ...pcStyles.itemsStart,
1157
+ borderRadius: pcStyles.borderRadiusMd,
1158
+ border: `1px solid var(--pc-border-color)`,
1159
+ backgroundColor: `var(--pc-bg-secondary)`,
1160
+ padding: getVar('--pc-spacing-md'),
1161
+ minHeight: '100px',
1162
+ ...(isPopup ? {
1163
+ gap: '0.5rem',
1164
+ ...pcStyles.overflowYAuto,
1165
+ } : {
1166
+ gap: getVar('--pc-spacing-sm'),
1167
+ }),
1168
+ }}
1169
+ >
1170
+ {/* Always render documents list container to prevent layout shift */}
1171
+ {(isDocument && uploadedDocuments.length > 0) && (
1172
+ <div
1173
+ style={{
1174
+ ...pcStyles.flex,
1175
+ ...pcStyles.gapSm,
1176
+ ...(isPopup ? { flexWrap: 'wrap' } : {}),
1177
+ width: '100%',
1178
+ }}
1179
+ >
1180
+ {uploadedDocuments.map((doc) => (
1181
+ <div
1182
+ key={doc.uuid}
1183
+ style={{
1184
+ position: 'relative',
1185
+ ...pcStyles.flex,
1186
+ ...pcStyles.itemsCenter,
1187
+ ...pcStyles.gapXs,
1188
+ borderRadius: pcStyles.borderRadiusMd,
1189
+ border: `1px solid var(--pc-border-color)`,
1190
+ backgroundColor: `var(--pc-bg-primary)`,
1191
+ padding: getVar('--pc-spacing-sm'),
1192
+ }}
1193
+ >
1194
+ <img
1195
+ src={fileAttachIcon}
1196
+ alt="fileAttach"
1197
+ width={24}
1198
+ height={24}
1199
+ />
1200
+ <p
1201
+ style={{
1202
+ fontWeight: pcStyles.fontWeightMedium.fontWeight,
1203
+ color: `var(--pc-text-primary)`,
1204
+ ...(isPopup ? {
1205
+ maxWidth: '100px',
1206
+ overflow: 'hidden',
1207
+ textOverflow: 'ellipsis',
1208
+ whiteSpace: 'nowrap',
1209
+ fontSize: pcStyles.textXs.fontSize,
1210
+ } : {
1211
+ fontSize: pcStyles.textSm.fontSize,
1212
+ }),
1213
+ }}
1214
+ >
1215
+ {doc.name}
1216
+ </p>
1217
+ <div
1218
+ style={{
1219
+ position: 'absolute',
1220
+ right: '-0.25rem',
1221
+ top: '-0.5rem',
1222
+ cursor: 'pointer',
1223
+ }}
1224
+ onClick={(e) => {
1225
+ e.stopPropagation();
1226
+ handleRemoveDocument(doc.uuid);
1227
+ }}
1228
+ >
1229
+ <div style={{
1230
+ borderRadius: '50%',
1231
+ border: `1px solid var(--pc-border-color)`,
1232
+ backgroundColor: `var(--pc-bg-secondary)`,
1233
+ padding: '0.125rem',
1234
+ }}>
1235
+ <AiOutlineClose size={12} style={{ color: 'var(--pc-error)' }} />
1236
+ </div>
1237
+ </div>
1238
+ </div>
1239
+ ))}
1240
+ </div>
1241
+ )}
1242
+
1243
+ {/* Upload button or loading state - always in same position */}
1244
+ <div style={{
1245
+ width: uploadedDocuments.length > 0 ? 'auto' : '100%',
1246
+ ...pcStyles.flex,
1247
+ ...pcStyles.itemsCenter,
1248
+ ...pcStyles.justifyCenter,
1249
+ minHeight: uploadedDocuments.length > 0 ? 'auto' : '100px',
1250
+ }}>
1251
+ {isDocumentUploading ? (
1252
+ <div style={{
1253
+ padding: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-lg'),
1254
+ borderRadius: pcStyles.borderRadiusMd,
1255
+ border: uploadedDocuments.length > 0 ? `1px solid var(--pc-border-color)` : 'none',
1256
+ backgroundColor: `var(--pc-bg-primary)`,
1257
+ fontWeight: pcStyles.fontWeightSemibold.fontWeight,
1258
+ color: `var(--pc-text-primary)`,
1259
+ ...pcStyles.flex,
1260
+ ...pcStyles.itemsCenter,
1261
+ ...pcStyles.justifyCenter,
1262
+ gap: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-md'),
1263
+ width: 'auto',
1264
+ }}>
1265
+ <PCLoader variant="spinner" size="sm" />
1266
+ <span>{t('uploading')}</span>
1267
+ </div>
1268
+ ) : (
1269
+ <div
1270
+ style={{
1271
+ ...pcStyles.flex,
1272
+ width: uploadedDocuments.length > 0 ? 'auto' : '100%',
1273
+ cursor: 'pointer',
1274
+ ...pcStyles.itemsCenter,
1275
+ ...pcStyles.justifyCenter,
1276
+ gap: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-md'),
1277
+ borderRadius: pcStyles.borderRadiusMd,
1278
+ padding: uploadedDocuments.length > 0 ? getVar('--pc-spacing-sm') : getVar('--pc-spacing-lg'),
1279
+ border: uploadedDocuments.length > 0 ? `1px solid var(--pc-border-color)` : 'none',
1280
+ backgroundColor: uploadedDocuments.length > 0 ? `var(--pc-bg-primary)` : 'transparent',
1281
+ transition: 'all 0.2s ease',
1282
+ }}
1283
+ onMouseEnter={(e) => {
1284
+ e.currentTarget.style.backgroundColor = uploadedDocuments.length > 0
1285
+ ? `var(--pc-bg-hover)`
1286
+ : `var(--pc-bg-hover)`;
1287
+ if (uploadedDocuments.length > 0) {
1288
+ e.currentTarget.style.borderColor = `var(--pc-primary)`;
1289
+ }
1290
+ }}
1291
+ onMouseLeave={(e) => {
1292
+ e.currentTarget.style.backgroundColor = uploadedDocuments.length > 0
1293
+ ? `var(--pc-bg-primary)`
1294
+ : 'transparent';
1295
+ if (uploadedDocuments.length > 0) {
1296
+ e.currentTarget.style.borderColor = `var(--pc-border-color)`;
1297
+ }
1298
+ }}
1299
+ role="button"
1300
+ tabIndex={0}
1301
+ aria-label={t('uploadFile')}
1302
+ onClick={() =>
1303
+ document
1304
+ .getElementById('privacy-file-upload')
1305
+ ?.click()
1306
+ }
1307
+ onKeyDown={(e) => {
1308
+ if (e.key === 'Enter' || e.key === ' ') {
1309
+ e.preventDefault();
1310
+ document.getElementById('privacy-file-upload')?.click();
1311
+ }
1312
+ }}
1313
+ >
1314
+ {uploadedDocuments.length === 0 ? (
1315
+ <>
1316
+ <div>
1317
+ <img
1318
+ src={uploadFileIcon}
1319
+ width={44}
1320
+ height={44}
1321
+ alt="upload"
1322
+ />
1323
+ </div>
1324
+ <span style={{
1325
+ color: 'var(--pc-text-secondary)',
1326
+ fontSize: pcStyles.textSm.fontSize,
1327
+ }}>
1328
+ {t('uploadFile')}
1329
+ </span>
1330
+ </>
1331
+ ) : (
1332
+ <div style={{ ...pcStyles.flex, ...pcStyles.itemsCenter, ...pcStyles.justifyCenter, ...pcStyles.gapSm }}>
1333
+ <PiUploadBold
1334
+ size={isPopup ? 20 : 16}
1335
+ style={{ color: 'var(--pc-text-secondary)' }}
1336
+ />
1337
+ {!isPopup && (
1338
+ <p style={{
1339
+ fontWeight: pcStyles.fontWeightMedium.fontWeight,
1340
+ color: `var(--pc-text-primary)`,
1341
+ }}>
1342
+ {t('uploadFileButton')}
1343
+ </p>
1344
+ )}
1345
+ </div>
1346
+ )}
1347
+ </div>
1348
+ )}
1349
+ </div>
1350
+ </div>
1351
+ </div>
1352
+ {requestType === RequestTypeEnum.ACCESS && (
1353
+ <PCSelect
1354
+ label={t('timePeriod')}
1355
+ options={timePeriodOptions}
1356
+ value={timePeriodOptions.find(
1357
+ (opt) => opt.value === timePeriod
1358
+ ) || null}
1359
+ onChange={(value) => {
1360
+ if (value) {
1361
+ setTimePeriod(value.value as string);
1362
+ }
1363
+ }}
1364
+ disabled={isPopup && !isPopupFormEnabled}
1365
+ />
1366
+ )}
1367
+ <PCTextarea
1368
+ label={t('additionalNote')}
1369
+ placeholder={t('reasonOptional')}
1370
+ value={formData.additionalNote}
1371
+ onChange={(e) =>
1372
+ setFormData({
1373
+ ...formData,
1374
+ additionalNote: e.target.value,
1375
+ })
1376
+ }
1377
+ style={isPopup ? { minHeight: '60px' } : {}}
1378
+ disabled={isPopup && !isPopupFormEnabled}
1379
+ />
1380
+ <PCCheckbox
1381
+ size="sm"
1382
+ label={
1383
+ <span>
1384
+ {t('confirmCheckBox')}{' '}
1385
+ <span style={{ color: getVar('--pc-error') }}>*</span>
1386
+ </span>
1387
+ }
1388
+ checked={formData.confirmCheckBox}
1389
+ onChange={(e) =>
1390
+ setFormData({
1391
+ ...formData,
1392
+ confirmCheckBox: e.target.checked,
1393
+ })
1394
+ }
1395
+ disabled={isPopup && !isPopupFormEnabled}
1396
+ />
1397
+ <div
1398
+ style={{
1399
+ ...pcStyles.flex,
1400
+ ...pcStyles.flexRow,
1401
+ ...pcStyles.justifyEnd,
1402
+ gap: getVar('--pc-spacing-sm'),
1403
+ ...(isPopup ? {
1404
+ position: 'sticky',
1405
+ bottom: 0,
1406
+ left: 0,
1407
+ right: 0,
1408
+ zIndex: 20,
1409
+ borderTop: `1px solid ${getVar('--pc-border-color')}`,
1410
+ backgroundColor: getVar('--pc-bg-primary'),
1411
+ padding: '0.75rem',
1412
+ marginLeft: '-0.75rem',
1413
+ marginRight: '-0.75rem',
1414
+ } : {
1415
+ marginTop: pcStyles.mtMd.marginTop,
1416
+ }),
1417
+ }}
1418
+ className="pc-flex-responsive pc-spacing-responsive"
1419
+ >
1420
+ {isPopup ? (
1421
+ <PCButton variant="outline" onClick={onClose} style={{ flexShrink: 0 }}>
1422
+ {t('cancel') ?? 'Cancel'}
1423
+ </PCButton>
1424
+ ) : (
1425
+ <PCButton variant="outline" style={{ flexShrink: 0 }}>
1426
+ {t('saveDraft')}
1427
+ </PCButton>
1428
+ )}
1429
+ <PCButton
1430
+ variant="primary"
1431
+ onClick={handleSubmit}
1432
+ disabled={!isFormValid || isLoading}
1433
+ isLoading={isLoading}
1434
+ style={{
1435
+ ...pcStyles.flex,
1436
+ ...pcStyles.itemsCenter,
1437
+ ...pcStyles.justifyCenter,
1438
+ minWidth: '120px',
1439
+ flexShrink: 0,
1440
+ opacity: (!isFormValid || isLoading) ? 0.5 : 1,
1441
+ cursor: (!isFormValid || isLoading) ? 'not-allowed' : (isLoading ? 'wait' : 'pointer'),
1442
+ }}
1443
+ >
1444
+ {t('submit')}
1445
+ </PCButton>
1446
+ </div>
1447
+ </div>
1448
+ </FormBlockWrapper>
1449
+ </div>
1450
+ </div>
1451
+ </div>
1452
+ )}
1453
+ </>
1454
+ );
1455
+ }