@redacto.io/consent-sdk-react 0.0.3 → 1.1.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.
- package/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +17 -0
- package/dist/index.d.mts +58 -4
- package/dist/index.d.ts +58 -4
- package/dist/index.js +3463 -1008
- package/dist/index.mjs +3466 -1005
- package/package.json +2 -3
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +506 -19
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1574 -315
- package/src/RedactoNoticeConsent/api/index.ts +267 -46
- package/src/RedactoNoticeConsent/api/types.ts +76 -0
- package/src/RedactoNoticeConsent/injectStyles.ts +16 -0
- package/src/RedactoNoticeConsent/styles.ts +1 -1
- package/src/RedactoNoticeConsent/types.ts +25 -0
- package/src/RedactoNoticeConsent/useTextToSpeech.ts +378 -0
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.test.tsx +369 -0
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +597 -0
- package/src/RedactoNoticeConsentInline/api/index.ts +159 -0
- package/src/RedactoNoticeConsentInline/api/types.ts +190 -0
- package/src/RedactoNoticeConsentInline/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeConsentInline/index.ts +1 -0
- package/src/RedactoNoticeConsentInline/injectStyles.ts +40 -0
- package/src/RedactoNoticeConsentInline/styles.ts +397 -0
- package/src/RedactoNoticeConsentInline/types.ts +45 -0
- package/src/RedactoNoticeConsentInline/useMediaQuery.ts +36 -0
- package/src/index.ts +1 -0
- package/tests/mocks.ts +98 -2
- package/tests/setup.ts +15 -0
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.changeset/fifty-candies-drop.md +0 -5
|
@@ -1,12 +1,692 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Redacto Notice Consent Component
|
|
3
|
+
*
|
|
4
|
+
* A comprehensive consent management component that handles both initial consent
|
|
5
|
+
* collection and reconsent flows with visual distinction between already-consented
|
|
6
|
+
* and needs-consent purposes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// React imports
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
|
|
12
|
+
// API imports
|
|
13
|
+
import {
|
|
14
|
+
clearApiCache,
|
|
15
|
+
fetchConsentContent,
|
|
16
|
+
submitConsentEvent,
|
|
17
|
+
submitGuardianInfo,
|
|
18
|
+
} from "./api";
|
|
19
|
+
|
|
20
|
+
// Type imports
|
|
21
|
+
import type {
|
|
22
|
+
ConsentContent,
|
|
23
|
+
PurposeItemData,
|
|
24
|
+
PurposeItemProps,
|
|
25
|
+
} from "./api/types";
|
|
4
26
|
import type { Props, TranslationObject } from "./types";
|
|
5
|
-
|
|
27
|
+
|
|
28
|
+
// Asset imports
|
|
6
29
|
import logo from "./assets/redacto-logo.png";
|
|
7
|
-
|
|
30
|
+
|
|
31
|
+
// Utility imports
|
|
8
32
|
import { injectCheckboxStyles } from "./injectStyles";
|
|
33
|
+
import { styles } from "./styles";
|
|
34
|
+
import { useMediaQuery } from "./useMediaQuery";
|
|
35
|
+
import { useTextToSpeech } from "./useTextToSpeech";
|
|
36
|
+
import type { TextSegment } from "./types";
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// SUB-COMPONENTS
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* PurposeItem Component
|
|
44
|
+
*
|
|
45
|
+
* Displays an individual purpose with its data elements.
|
|
46
|
+
* Supports both regular consent and reconsent UI modes.
|
|
47
|
+
*/
|
|
48
|
+
const PurposeItem: React.FC<PurposeItemProps> = ({
|
|
49
|
+
purpose,
|
|
50
|
+
selectedPurposes,
|
|
51
|
+
collapsedPurposes,
|
|
52
|
+
selectedDataElements,
|
|
53
|
+
settings,
|
|
54
|
+
styles,
|
|
55
|
+
responsiveStyles,
|
|
56
|
+
onPurposeToggle,
|
|
57
|
+
onPurposeCollapse,
|
|
58
|
+
onDataElementToggle,
|
|
59
|
+
getTranslatedText,
|
|
60
|
+
isAlreadyConsented,
|
|
61
|
+
}) => {
|
|
62
|
+
const headingStyle = useMemo(
|
|
63
|
+
() => ({
|
|
64
|
+
color: settings?.headingColor || "#323B4B",
|
|
65
|
+
fontFamily: settings?.font || "inherit",
|
|
66
|
+
}),
|
|
67
|
+
[settings]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const textStyle = useMemo(
|
|
71
|
+
() => ({
|
|
72
|
+
color: settings?.textColor || "#344054",
|
|
73
|
+
fontFamily: settings?.font || "inherit",
|
|
74
|
+
}),
|
|
75
|
+
[settings]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div key={purpose.uuid}>
|
|
80
|
+
<div style={styles.optionItem}>
|
|
81
|
+
<div
|
|
82
|
+
style={styles.optionLeft}
|
|
83
|
+
onClick={() => onPurposeCollapse(purpose.uuid)}
|
|
84
|
+
role="button"
|
|
85
|
+
tabIndex={0}
|
|
86
|
+
aria-expanded={!collapsedPurposes[purpose.uuid]}
|
|
87
|
+
aria-controls={`purpose-${purpose.uuid}`}
|
|
88
|
+
onKeyDown={(e) => {
|
|
89
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
onPurposeCollapse(purpose.uuid);
|
|
92
|
+
}
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<div
|
|
96
|
+
style={{
|
|
97
|
+
display: "flex",
|
|
98
|
+
alignItems: "center",
|
|
99
|
+
justifyContent: "center",
|
|
100
|
+
marginLeft: "5px",
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<svg
|
|
104
|
+
width="7"
|
|
105
|
+
height="12"
|
|
106
|
+
viewBox="0 0 7 12"
|
|
107
|
+
fill="none"
|
|
108
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
109
|
+
stroke={settings?.headingColor || "#323B4B"}
|
|
110
|
+
strokeWidth="2"
|
|
111
|
+
strokeLinecap="round"
|
|
112
|
+
strokeLinejoin="round"
|
|
113
|
+
style={{
|
|
114
|
+
transform: !collapsedPurposes[purpose.uuid]
|
|
115
|
+
? "rotate(90deg)"
|
|
116
|
+
: "rotate(0deg)",
|
|
117
|
+
transition: "transform 0.3s ease",
|
|
118
|
+
}}
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
>
|
|
121
|
+
<path d="M1 1L6 6L1 11" />
|
|
122
|
+
</svg>
|
|
123
|
+
</div>
|
|
124
|
+
<div style={styles.optionTextContainer}>
|
|
125
|
+
<h2
|
|
126
|
+
id={`purpose-name-${purpose.uuid}`}
|
|
127
|
+
style={{
|
|
128
|
+
...styles.optionTitle,
|
|
129
|
+
...responsiveStyles.optionTitle,
|
|
130
|
+
...headingStyle,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{getTranslatedText(`purposes.name`, purpose.name, purpose.uuid)}
|
|
134
|
+
</h2>
|
|
135
|
+
<h3
|
|
136
|
+
id={`purpose-description-${purpose.uuid}`}
|
|
137
|
+
style={{
|
|
138
|
+
...styles.optionDescription,
|
|
139
|
+
...responsiveStyles.optionDescription,
|
|
140
|
+
...textStyle,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{getTranslatedText(
|
|
144
|
+
`purposes.description`,
|
|
145
|
+
purpose.description,
|
|
146
|
+
purpose.uuid
|
|
147
|
+
)}
|
|
148
|
+
</h3>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
{!isAlreadyConsented && (
|
|
152
|
+
<input
|
|
153
|
+
className="redacto-checkbox-large"
|
|
154
|
+
type="checkbox"
|
|
155
|
+
checked={selectedPurposes[purpose.uuid] || false}
|
|
156
|
+
onChange={() => onPurposeToggle(purpose.uuid)}
|
|
157
|
+
aria-label={`Select all data elements for ${getTranslatedText(
|
|
158
|
+
`purposes.name`,
|
|
159
|
+
purpose.name,
|
|
160
|
+
purpose.uuid
|
|
161
|
+
)}`}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
{isAlreadyConsented && (
|
|
165
|
+
<div
|
|
166
|
+
style={{
|
|
167
|
+
display: "flex",
|
|
168
|
+
alignItems: "center",
|
|
169
|
+
justifyContent: "center",
|
|
170
|
+
width: "20px",
|
|
171
|
+
height: "20px",
|
|
172
|
+
borderRadius: "50%",
|
|
173
|
+
backgroundColor: "#10B981",
|
|
174
|
+
marginLeft: "12px",
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<svg
|
|
178
|
+
width="12"
|
|
179
|
+
height="12"
|
|
180
|
+
viewBox="0 0 12 12"
|
|
181
|
+
fill="none"
|
|
182
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
183
|
+
>
|
|
184
|
+
<path
|
|
185
|
+
d="M4 6L5.5 7.5L8.5 4.5"
|
|
186
|
+
stroke="white"
|
|
187
|
+
strokeWidth="2"
|
|
188
|
+
strokeLinecap="round"
|
|
189
|
+
strokeLinejoin="round"
|
|
190
|
+
/>
|
|
191
|
+
</svg>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
{!collapsedPurposes[purpose.uuid] &&
|
|
196
|
+
purpose.data_elements &&
|
|
197
|
+
purpose.data_elements.length > 0 && (
|
|
198
|
+
<div
|
|
199
|
+
id={`purpose-${purpose.uuid}`}
|
|
200
|
+
style={styles.dataElementsContainer}
|
|
201
|
+
>
|
|
202
|
+
{purpose.data_elements.map((dataElement: any) => (
|
|
203
|
+
<div key={dataElement.uuid} style={styles.dataElementItem}>
|
|
204
|
+
<span
|
|
205
|
+
id={`data-element-${purpose.uuid}-${dataElement.uuid}`}
|
|
206
|
+
style={{
|
|
207
|
+
...styles.dataElementText,
|
|
208
|
+
...responsiveStyles.dataElementText,
|
|
209
|
+
...textStyle,
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{getTranslatedText(
|
|
213
|
+
`data_elements.name`,
|
|
214
|
+
dataElement.name,
|
|
215
|
+
dataElement.uuid
|
|
216
|
+
)}
|
|
217
|
+
{dataElement.required && (
|
|
218
|
+
<span
|
|
219
|
+
style={{
|
|
220
|
+
color: "red",
|
|
221
|
+
marginLeft: "4px",
|
|
222
|
+
}}
|
|
223
|
+
aria-label="required"
|
|
224
|
+
>
|
|
225
|
+
*
|
|
226
|
+
</span>
|
|
227
|
+
)}
|
|
228
|
+
</span>
|
|
229
|
+
{!isAlreadyConsented && (
|
|
230
|
+
<input
|
|
231
|
+
className="redacto-checkbox-small"
|
|
232
|
+
type="checkbox"
|
|
233
|
+
checked={
|
|
234
|
+
selectedDataElements[
|
|
235
|
+
`${purpose.uuid}-${dataElement.uuid}`
|
|
236
|
+
] || false
|
|
237
|
+
}
|
|
238
|
+
onChange={() =>
|
|
239
|
+
onDataElementToggle(dataElement.uuid, purpose.uuid)
|
|
240
|
+
}
|
|
241
|
+
aria-label={`Select ${getTranslatedText(
|
|
242
|
+
`data_elements.name`,
|
|
243
|
+
dataElement.name,
|
|
244
|
+
dataElement.uuid
|
|
245
|
+
)}${dataElement.required ? " (required)" : ""}`}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
248
|
+
{isAlreadyConsented && (
|
|
249
|
+
<div
|
|
250
|
+
style={{
|
|
251
|
+
display: "flex",
|
|
252
|
+
alignItems: "center",
|
|
253
|
+
justifyContent: "center",
|
|
254
|
+
width: "16px",
|
|
255
|
+
height: "16px",
|
|
256
|
+
borderRadius: "50%",
|
|
257
|
+
backgroundColor: "#10B981",
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<svg
|
|
261
|
+
width="10"
|
|
262
|
+
height="10"
|
|
263
|
+
viewBox="0 0 10 10"
|
|
264
|
+
fill="none"
|
|
265
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
266
|
+
>
|
|
267
|
+
<path
|
|
268
|
+
d="M3 5L4.5 6.5L7.5 3.5"
|
|
269
|
+
stroke="white"
|
|
270
|
+
strokeWidth="1.5"
|
|
271
|
+
strokeLinecap="round"
|
|
272
|
+
strokeLinejoin="round"
|
|
273
|
+
/>
|
|
274
|
+
</svg>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* GuardianForm Component
|
|
287
|
+
*
|
|
288
|
+
* Displays a form for collecting guardian information for minor consent.
|
|
289
|
+
*/
|
|
290
|
+
const GuardianForm: React.FC<{
|
|
291
|
+
formData: {
|
|
292
|
+
guardianName: string;
|
|
293
|
+
guardianContact: string;
|
|
294
|
+
guardianRelationship: string;
|
|
295
|
+
};
|
|
296
|
+
errors: Record<string, string>;
|
|
297
|
+
isSubmitting: boolean;
|
|
298
|
+
onChange: (field: string, value: string) => void;
|
|
299
|
+
onNext: () => void;
|
|
300
|
+
onDecline: () => void;
|
|
301
|
+
styles: any;
|
|
302
|
+
responsiveStyles: any;
|
|
303
|
+
settings?: any;
|
|
304
|
+
componentHeadingStyle: any;
|
|
305
|
+
componentTextStyle: any;
|
|
306
|
+
acceptButtonStyle: any;
|
|
307
|
+
declineButtonStyle: any;
|
|
308
|
+
logoUrl: string;
|
|
309
|
+
}> = ({
|
|
310
|
+
formData,
|
|
311
|
+
errors,
|
|
312
|
+
isSubmitting,
|
|
313
|
+
onChange,
|
|
314
|
+
onNext,
|
|
315
|
+
onDecline,
|
|
316
|
+
styles,
|
|
317
|
+
responsiveStyles,
|
|
318
|
+
settings,
|
|
319
|
+
componentHeadingStyle,
|
|
320
|
+
componentTextStyle,
|
|
321
|
+
acceptButtonStyle,
|
|
322
|
+
declineButtonStyle,
|
|
323
|
+
logoUrl,
|
|
324
|
+
}) => {
|
|
325
|
+
const inputStyle = {
|
|
326
|
+
width: "100%",
|
|
327
|
+
padding: "10px 12px",
|
|
328
|
+
border: `1px solid ${settings?.borderColor || "#d0d5dd"}`,
|
|
329
|
+
borderRadius: settings?.borderRadius || "8px",
|
|
330
|
+
fontSize: "13px",
|
|
331
|
+
fontFamily: settings?.font || "inherit",
|
|
332
|
+
color: settings?.textColor || "#344054",
|
|
333
|
+
backgroundColor: settings?.backgroundColor || "#ffffff",
|
|
334
|
+
marginBottom: "4px",
|
|
335
|
+
boxSizing: "border-box" as const,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const errorStyle = {
|
|
339
|
+
color: "#DC2626",
|
|
340
|
+
fontSize: "12px",
|
|
341
|
+
marginTop: "4px",
|
|
342
|
+
marginBottom: "16px",
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const labelStyle = {
|
|
346
|
+
...componentTextStyle,
|
|
347
|
+
fontSize: "13px",
|
|
348
|
+
fontWeight: "500",
|
|
349
|
+
marginBottom: "6px",
|
|
350
|
+
display: "block",
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<>
|
|
355
|
+
<div style={{ ...styles.topSection, ...componentTextStyle }}>
|
|
356
|
+
<div style={styles.topLeft}>
|
|
357
|
+
<img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
|
|
358
|
+
<h2
|
|
359
|
+
id="guardian-form-title"
|
|
360
|
+
style={{
|
|
361
|
+
...styles.title,
|
|
362
|
+
...responsiveStyles.title,
|
|
363
|
+
...componentHeadingStyle,
|
|
364
|
+
fontSize: "16px",
|
|
365
|
+
fontWeight: 600,
|
|
366
|
+
}}
|
|
367
|
+
>
|
|
368
|
+
Guardian Information Required
|
|
369
|
+
</h2>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<div
|
|
374
|
+
style={{
|
|
375
|
+
...styles.middleSection,
|
|
376
|
+
...responsiveStyles.middleSection,
|
|
377
|
+
...componentTextStyle,
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<p
|
|
381
|
+
style={{
|
|
382
|
+
...styles.privacyText,
|
|
383
|
+
...responsiveStyles.privacyText,
|
|
384
|
+
...componentTextStyle,
|
|
385
|
+
fontSize: "14px",
|
|
386
|
+
lineHeight: "1.4",
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
As you are under the age of consent, we need to collect information
|
|
390
|
+
about your legal guardian to proceed.
|
|
391
|
+
</p>
|
|
392
|
+
|
|
393
|
+
<div
|
|
394
|
+
style={{
|
|
395
|
+
display: "flex",
|
|
396
|
+
flexDirection: "column",
|
|
397
|
+
gap: "12px",
|
|
398
|
+
marginTop: "16px",
|
|
399
|
+
}}
|
|
400
|
+
>
|
|
401
|
+
<div>
|
|
402
|
+
<label
|
|
403
|
+
htmlFor="guardianName"
|
|
404
|
+
style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
|
|
405
|
+
>
|
|
406
|
+
Name of Guardian *
|
|
407
|
+
</label>
|
|
408
|
+
<input
|
|
409
|
+
id="guardianName"
|
|
410
|
+
type="text"
|
|
411
|
+
value={formData.guardianName}
|
|
412
|
+
onChange={(e) => onChange("guardianName", e.target.value)}
|
|
413
|
+
style={{
|
|
414
|
+
...inputStyle,
|
|
415
|
+
borderColor: errors.guardianName
|
|
416
|
+
? "#DC2626"
|
|
417
|
+
: inputStyle.border,
|
|
418
|
+
}}
|
|
419
|
+
placeholder="Enter guardian's full name"
|
|
420
|
+
/>
|
|
421
|
+
{errors.guardianName && (
|
|
422
|
+
<div style={errorStyle}>{errors.guardianName}</div>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div>
|
|
427
|
+
<label
|
|
428
|
+
htmlFor="guardianContact"
|
|
429
|
+
style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
|
|
430
|
+
>
|
|
431
|
+
Contact of Guardian *
|
|
432
|
+
</label>
|
|
433
|
+
<input
|
|
434
|
+
id="guardianContact"
|
|
435
|
+
type="text"
|
|
436
|
+
value={formData.guardianContact}
|
|
437
|
+
onChange={(e) => onChange("guardianContact", e.target.value)}
|
|
438
|
+
style={{
|
|
439
|
+
...inputStyle,
|
|
440
|
+
borderColor: errors.guardianContact
|
|
441
|
+
? "#DC2626"
|
|
442
|
+
: inputStyle.border,
|
|
443
|
+
}}
|
|
444
|
+
placeholder="Enter guardian's email or phone number"
|
|
445
|
+
/>
|
|
446
|
+
{errors.guardianContact && (
|
|
447
|
+
<div style={errorStyle}>{errors.guardianContact}</div>
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
<div>
|
|
452
|
+
<label
|
|
453
|
+
htmlFor="guardianRelationship"
|
|
454
|
+
style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
|
|
455
|
+
>
|
|
456
|
+
Relationship to Guardian *
|
|
457
|
+
</label>
|
|
458
|
+
<input
|
|
459
|
+
id="guardianRelationship"
|
|
460
|
+
type="text"
|
|
461
|
+
value={formData.guardianRelationship}
|
|
462
|
+
onChange={(e) => onChange("guardianRelationship", e.target.value)}
|
|
463
|
+
style={{
|
|
464
|
+
...inputStyle,
|
|
465
|
+
borderColor: errors.guardianRelationship
|
|
466
|
+
? "#DC2626"
|
|
467
|
+
: inputStyle.border,
|
|
468
|
+
}}
|
|
469
|
+
placeholder="e.g., Parent, Legal Guardian, etc."
|
|
470
|
+
/>
|
|
471
|
+
{errors.guardianRelationship && (
|
|
472
|
+
<div style={errorStyle}>{errors.guardianRelationship}</div>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
{errors.general && (
|
|
478
|
+
<div
|
|
479
|
+
role="alert"
|
|
480
|
+
style={{
|
|
481
|
+
color: "#DC2626",
|
|
482
|
+
fontSize: "14px",
|
|
483
|
+
marginTop: "16px",
|
|
484
|
+
padding: "12px",
|
|
485
|
+
borderRadius: "6px",
|
|
486
|
+
border: "1px solid #FCA5A5",
|
|
487
|
+
backgroundColor: "#FEF2F2",
|
|
488
|
+
textAlign: "center",
|
|
489
|
+
}}
|
|
490
|
+
>
|
|
491
|
+
{errors.general}
|
|
492
|
+
</div>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
9
495
|
|
|
496
|
+
<div
|
|
497
|
+
style={{
|
|
498
|
+
...styles.bottomSection,
|
|
499
|
+
...responsiveStyles.bottomSection,
|
|
500
|
+
...componentTextStyle,
|
|
501
|
+
paddingTop: "12px",
|
|
502
|
+
}}
|
|
503
|
+
>
|
|
504
|
+
<button
|
|
505
|
+
style={{
|
|
506
|
+
...styles.button,
|
|
507
|
+
...responsiveStyles.button,
|
|
508
|
+
...styles.acceptButton,
|
|
509
|
+
...acceptButtonStyle,
|
|
510
|
+
}}
|
|
511
|
+
onClick={onNext}
|
|
512
|
+
disabled={isSubmitting}
|
|
513
|
+
type="button"
|
|
514
|
+
>
|
|
515
|
+
{isSubmitting ? "Submitting..." : "Next"}
|
|
516
|
+
</button>
|
|
517
|
+
<button
|
|
518
|
+
style={{
|
|
519
|
+
...styles.button,
|
|
520
|
+
...responsiveStyles.button,
|
|
521
|
+
...styles.cancelButton,
|
|
522
|
+
...declineButtonStyle,
|
|
523
|
+
}}
|
|
524
|
+
onClick={onDecline}
|
|
525
|
+
type="button"
|
|
526
|
+
>
|
|
527
|
+
Cancel
|
|
528
|
+
</button>
|
|
529
|
+
</div>
|
|
530
|
+
</>
|
|
531
|
+
);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* AgeVerification Component
|
|
536
|
+
*
|
|
537
|
+
* Displays an age verification screen asking if the user is 18+.
|
|
538
|
+
*/
|
|
539
|
+
const AgeVerification: React.FC<{
|
|
540
|
+
onYes: () => void;
|
|
541
|
+
onNo: () => void;
|
|
542
|
+
onClose: () => void;
|
|
543
|
+
styles: any;
|
|
544
|
+
responsiveStyles: any;
|
|
545
|
+
componentHeadingStyle: any;
|
|
546
|
+
componentTextStyle: any;
|
|
547
|
+
acceptButtonStyle: any;
|
|
548
|
+
declineButtonStyle: any;
|
|
549
|
+
logoUrl: string;
|
|
550
|
+
isMobile: boolean;
|
|
551
|
+
}> = ({
|
|
552
|
+
onYes,
|
|
553
|
+
onNo,
|
|
554
|
+
onClose,
|
|
555
|
+
styles,
|
|
556
|
+
responsiveStyles,
|
|
557
|
+
componentHeadingStyle,
|
|
558
|
+
componentTextStyle,
|
|
559
|
+
acceptButtonStyle,
|
|
560
|
+
declineButtonStyle,
|
|
561
|
+
logoUrl,
|
|
562
|
+
isMobile,
|
|
563
|
+
}) => {
|
|
564
|
+
return (
|
|
565
|
+
<>
|
|
566
|
+
<div style={{ ...styles.topSection, ...componentTextStyle }}>
|
|
567
|
+
<div style={styles.topLeft}>
|
|
568
|
+
<img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
|
|
569
|
+
<h2
|
|
570
|
+
id="age-verification-title"
|
|
571
|
+
style={{
|
|
572
|
+
...styles.title,
|
|
573
|
+
...responsiveStyles.title,
|
|
574
|
+
...componentHeadingStyle,
|
|
575
|
+
}}
|
|
576
|
+
>
|
|
577
|
+
Age Verification Required
|
|
578
|
+
</h2>
|
|
579
|
+
</div>
|
|
580
|
+
<button
|
|
581
|
+
onClick={onClose}
|
|
582
|
+
style={{
|
|
583
|
+
background: "none",
|
|
584
|
+
border: "none",
|
|
585
|
+
color: componentHeadingStyle?.color || "#323B4B",
|
|
586
|
+
cursor: "pointer",
|
|
587
|
+
padding: isMobile ? "2px" : "4px",
|
|
588
|
+
display: "flex",
|
|
589
|
+
alignItems: "center",
|
|
590
|
+
justifyContent: "center",
|
|
591
|
+
}}
|
|
592
|
+
aria-label="Close modal"
|
|
593
|
+
>
|
|
594
|
+
<svg
|
|
595
|
+
width={isMobile ? "18" : "20"}
|
|
596
|
+
height={isMobile ? "18" : "20"}
|
|
597
|
+
viewBox="0 0 20 20"
|
|
598
|
+
fill="none"
|
|
599
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
600
|
+
aria-hidden="true"
|
|
601
|
+
>
|
|
602
|
+
<path
|
|
603
|
+
d="M15 5L5 15M5 5L15 15"
|
|
604
|
+
stroke="currentColor"
|
|
605
|
+
strokeWidth="2"
|
|
606
|
+
strokeLinecap="round"
|
|
607
|
+
strokeLinejoin="round"
|
|
608
|
+
/>
|
|
609
|
+
</svg>
|
|
610
|
+
</button>
|
|
611
|
+
</div>
|
|
612
|
+
|
|
613
|
+
<div
|
|
614
|
+
style={{
|
|
615
|
+
...styles.middleSection,
|
|
616
|
+
...responsiveStyles.middleSection,
|
|
617
|
+
...componentTextStyle,
|
|
618
|
+
}}
|
|
619
|
+
>
|
|
620
|
+
<p
|
|
621
|
+
style={{
|
|
622
|
+
...styles.privacyText,
|
|
623
|
+
...responsiveStyles.privacyText,
|
|
624
|
+
...componentTextStyle,
|
|
625
|
+
marginBottom: "16px",
|
|
626
|
+
}}
|
|
627
|
+
>
|
|
628
|
+
To proceed with this consent form, we need to verify your age.
|
|
629
|
+
</p>
|
|
630
|
+
|
|
631
|
+
<p
|
|
632
|
+
style={{
|
|
633
|
+
...styles.subTitle,
|
|
634
|
+
...componentTextStyle,
|
|
635
|
+
marginBottom: "24px",
|
|
636
|
+
textAlign: "left",
|
|
637
|
+
}}
|
|
638
|
+
>
|
|
639
|
+
Are you 18 years of age or older?
|
|
640
|
+
</p>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
<div
|
|
644
|
+
style={{
|
|
645
|
+
...styles.bottomSection,
|
|
646
|
+
...responsiveStyles.bottomSection,
|
|
647
|
+
...componentTextStyle,
|
|
648
|
+
}}
|
|
649
|
+
>
|
|
650
|
+
<button
|
|
651
|
+
style={{
|
|
652
|
+
...styles.button,
|
|
653
|
+
...responsiveStyles.button,
|
|
654
|
+
...styles.acceptButton,
|
|
655
|
+
...acceptButtonStyle,
|
|
656
|
+
}}
|
|
657
|
+
onClick={onYes}
|
|
658
|
+
type="button"
|
|
659
|
+
>
|
|
660
|
+
Yes, I am 18 or older
|
|
661
|
+
</button>
|
|
662
|
+
<button
|
|
663
|
+
style={{
|
|
664
|
+
...styles.button,
|
|
665
|
+
...responsiveStyles.button,
|
|
666
|
+
...styles.cancelButton,
|
|
667
|
+
...declineButtonStyle,
|
|
668
|
+
}}
|
|
669
|
+
onClick={onNo}
|
|
670
|
+
type="button"
|
|
671
|
+
>
|
|
672
|
+
No, I am under 18
|
|
673
|
+
</button>
|
|
674
|
+
</div>
|
|
675
|
+
</>
|
|
676
|
+
);
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// =============================================================================
|
|
680
|
+
// MAIN COMPONENT
|
|
681
|
+
// =============================================================================
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* RedactoNoticeConsent
|
|
685
|
+
*
|
|
686
|
+
* Main consent management component that provides a comprehensive UI for users
|
|
687
|
+
* to view and manage their consent preferences. Supports both initial consent
|
|
688
|
+
* collection and reconsent flows.
|
|
689
|
+
*/
|
|
10
690
|
export const RedactoNoticeConsent = ({
|
|
11
691
|
noticeId,
|
|
12
692
|
accessToken,
|
|
@@ -19,37 +699,107 @@ export const RedactoNoticeConsent = ({
|
|
|
19
699
|
onError,
|
|
20
700
|
settings,
|
|
21
701
|
applicationId,
|
|
702
|
+
checkMode = "all",
|
|
22
703
|
}: Props) => {
|
|
704
|
+
// =============================================================================
|
|
705
|
+
// PROP VALIDATION
|
|
706
|
+
// =============================================================================
|
|
707
|
+
|
|
708
|
+
// Validate required props gracefully
|
|
709
|
+
const propValidationError = useMemo(() => {
|
|
710
|
+
if (!noticeId?.trim()) {
|
|
711
|
+
return "RedactoNoticeConsent: 'noticeId' prop is required and cannot be empty";
|
|
712
|
+
}
|
|
713
|
+
if (!accessToken?.trim()) {
|
|
714
|
+
return "RedactoNoticeConsent: 'accessToken' prop is required and cannot be empty";
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}, [noticeId, accessToken]);
|
|
718
|
+
|
|
719
|
+
// Show error UI if props are invalid
|
|
720
|
+
if (propValidationError) {
|
|
721
|
+
return (
|
|
722
|
+
<div
|
|
723
|
+
style={{
|
|
724
|
+
padding: "20px",
|
|
725
|
+
backgroundColor: "#fee",
|
|
726
|
+
border: "1px solid #fcc",
|
|
727
|
+
borderRadius: "4px",
|
|
728
|
+
color: "#c33",
|
|
729
|
+
}}
|
|
730
|
+
>
|
|
731
|
+
<h3>Configuration Error</h3>
|
|
732
|
+
<p>{propValidationError}</p>
|
|
733
|
+
<p>Please check your component props and try again.</p>
|
|
734
|
+
</div>
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// =============================================================================
|
|
739
|
+
// STATE MANAGEMENT
|
|
740
|
+
// =============================================================================
|
|
741
|
+
|
|
742
|
+
// Loading and submission states
|
|
743
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
744
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
745
|
+
|
|
746
|
+
// Content and consent data
|
|
23
747
|
const [content, setContent] = useState<
|
|
24
748
|
ConsentContent["detail"]["active_config"] | null
|
|
25
749
|
>(null);
|
|
26
|
-
const [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Record<string, boolean>
|
|
33
|
-
>({});
|
|
750
|
+
const [categorizedPurposes, setCategorizedPurposes] = useState<{
|
|
751
|
+
alreadyConsented: Array<PurposeItemData>;
|
|
752
|
+
needsConsent: Array<PurposeItemData>;
|
|
753
|
+
} | null>(null);
|
|
754
|
+
|
|
755
|
+
// User interaction states
|
|
34
756
|
const [selectedPurposes, setSelectedPurposes] = useState<
|
|
35
757
|
Record<string, boolean>
|
|
36
758
|
>({});
|
|
37
759
|
const [selectedDataElements, setSelectedDataElements] = useState<
|
|
38
760
|
Record<string, boolean>
|
|
39
761
|
>({});
|
|
762
|
+
const [collapsedPurposes, setCollapsedPurposes] = useState<
|
|
763
|
+
Record<string, boolean>
|
|
764
|
+
>({});
|
|
40
765
|
const [hasAlreadyConsented, setHasAlreadyConsented] = useState(false);
|
|
766
|
+
|
|
767
|
+
// UI states
|
|
768
|
+
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
|
|
769
|
+
const [selectedLanguage, setSelectedLanguage] = useState<string>(language);
|
|
41
770
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
42
771
|
const [fetchError, setFetchError] = useState<Error | null>(null);
|
|
772
|
+
|
|
773
|
+
// Age verification states
|
|
774
|
+
const [showAgeVerification, setShowAgeVerification] = useState(false);
|
|
775
|
+
|
|
776
|
+
// Guardian form states
|
|
777
|
+
const [showGuardianForm, setShowGuardianForm] = useState(false);
|
|
778
|
+
const [isSubmittingGuardian, setIsSubmittingGuardian] = useState(false);
|
|
779
|
+
const [guardianFormData, setGuardianFormData] = useState({
|
|
780
|
+
guardianName: "",
|
|
781
|
+
guardianContact: "",
|
|
782
|
+
guardianRelationship: "",
|
|
783
|
+
});
|
|
784
|
+
const [guardianFormErrors, setGuardianFormErrors] = useState<
|
|
785
|
+
Record<string, string>
|
|
786
|
+
>({});
|
|
787
|
+
|
|
788
|
+
// Refs and constants
|
|
43
789
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
44
790
|
const firstFocusableRef = useRef<HTMLButtonElement>(null);
|
|
45
791
|
const lastFocusableRef = useRef<HTMLButtonElement>(null);
|
|
46
|
-
const
|
|
47
|
-
const
|
|
792
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
793
|
+
const isRequestInProgressRef = useRef(false); // Prevent rapid successive calls
|
|
794
|
+
|
|
795
|
+
// =============================================================================
|
|
796
|
+
// RESPONSIVE DESIGN & STYLING
|
|
797
|
+
// =============================================================================
|
|
48
798
|
|
|
49
|
-
//
|
|
799
|
+
// Media query hooks
|
|
50
800
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
|
51
801
|
|
|
52
|
-
//
|
|
802
|
+
// Memoize responsive styles to avoid recalculation
|
|
53
803
|
const responsiveStyles = useMemo(
|
|
54
804
|
() => ({
|
|
55
805
|
modal: {
|
|
@@ -64,38 +814,21 @@ export const RedactoNoticeConsent = ({
|
|
|
64
814
|
subTitle: {
|
|
65
815
|
fontSize: isMobile ? "14px" : "16px",
|
|
66
816
|
},
|
|
67
|
-
privacyText: {
|
|
68
|
-
fontSize: isMobile ? "14px" : "16px",
|
|
69
|
-
},
|
|
70
817
|
optionTitle: {
|
|
71
818
|
fontSize: isMobile ? "14px" : "16px",
|
|
72
819
|
},
|
|
73
820
|
optionDescription: {
|
|
74
|
-
fontSize: isMobile ? "
|
|
821
|
+
fontSize: isMobile ? "12px" : "14px",
|
|
75
822
|
},
|
|
76
823
|
dataElementText: {
|
|
77
824
|
fontSize: isMobile ? "12px" : "14px",
|
|
78
825
|
},
|
|
79
|
-
topRight: {
|
|
80
|
-
fontSize: isMobile ? "11px" : "12px",
|
|
81
|
-
padding: isMobile ? "3px 6px" : "3px 9px",
|
|
82
|
-
height: isMobile ? "auto" : undefined,
|
|
83
|
-
width: isMobile ? "auto" : undefined,
|
|
84
|
-
},
|
|
85
|
-
languageItem: {
|
|
86
|
-
fontSize: isMobile ? "11px" : "12px",
|
|
87
|
-
padding: isMobile ? "6px 10px" : "8px 12px",
|
|
88
|
-
},
|
|
89
826
|
bottomSection: {
|
|
90
827
|
flexDirection: isMobile ? ("column" as const) : ("row" as const),
|
|
91
|
-
gap: isMobile ? "12px" : "
|
|
828
|
+
gap: isMobile ? "12px" : "16px",
|
|
92
829
|
},
|
|
93
|
-
|
|
830
|
+
privacyText: {
|
|
94
831
|
fontSize: isMobile ? "14px" : "16px",
|
|
95
|
-
padding: isMobile ? "10px 20px" : "9px 45px",
|
|
96
|
-
},
|
|
97
|
-
middleSection: {
|
|
98
|
-
paddingRight: isMobile ? "10px" : "15px",
|
|
99
832
|
},
|
|
100
833
|
errorDialog: {
|
|
101
834
|
padding: isMobile ? "12px" : "16px",
|
|
@@ -119,10 +852,60 @@ export const RedactoNoticeConsent = ({
|
|
|
119
852
|
right: isMobile ? "4px" : "8px",
|
|
120
853
|
padding: isMobile ? "2px" : "4px",
|
|
121
854
|
},
|
|
855
|
+
topRight: {
|
|
856
|
+
fontSize: isMobile ? "11px" : "12px",
|
|
857
|
+
padding: isMobile ? "3px 6px" : "3px 9px",
|
|
858
|
+
height: isMobile ? "auto" : undefined,
|
|
859
|
+
width: isMobile ? "auto" : undefined,
|
|
860
|
+
},
|
|
861
|
+
languageItem: {
|
|
862
|
+
fontSize: isMobile ? "11px" : "12px",
|
|
863
|
+
padding: isMobile ? "6px 10px" : "8px 12px",
|
|
864
|
+
},
|
|
865
|
+
middleSection: {
|
|
866
|
+
paddingRight: isMobile ? "10px" : "15px",
|
|
867
|
+
},
|
|
868
|
+
button: {
|
|
869
|
+
fontSize: isMobile ? "14px" : "16px",
|
|
870
|
+
padding: isMobile ? "10px 20px" : "9px 45px",
|
|
871
|
+
},
|
|
122
872
|
}),
|
|
123
873
|
[isMobile]
|
|
124
874
|
);
|
|
125
875
|
|
|
876
|
+
// Memoize component-level styles
|
|
877
|
+
const componentHeadingStyle = useMemo(
|
|
878
|
+
() => ({
|
|
879
|
+
color: settings?.headingColor || "#323B4B",
|
|
880
|
+
fontFamily: settings?.font || "inherit",
|
|
881
|
+
}),
|
|
882
|
+
[settings?.headingColor, settings?.font]
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
const componentTextStyle = useMemo(
|
|
886
|
+
() => ({
|
|
887
|
+
color: settings?.textColor || "#344054",
|
|
888
|
+
fontFamily: settings?.font || "inherit",
|
|
889
|
+
}),
|
|
890
|
+
[settings?.textColor, settings?.font]
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
// Memoize section style for buttons
|
|
894
|
+
const sectionStyle = useMemo(
|
|
895
|
+
() => ({
|
|
896
|
+
color: settings?.textColor || "#344054",
|
|
897
|
+
fontFamily: settings?.font || "inherit",
|
|
898
|
+
}),
|
|
899
|
+
[settings?.textColor, settings?.font]
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
// =============================================================================
|
|
903
|
+
// COMPUTED VALUES & BUSINESS LOGIC
|
|
904
|
+
// =============================================================================
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Checks if all required data elements are selected for consent submission
|
|
908
|
+
*/
|
|
126
909
|
const areAllRequiredElementsChecked = useMemo(() => {
|
|
127
910
|
if (!content) return false;
|
|
128
911
|
|
|
@@ -138,6 +921,46 @@ export const RedactoNoticeConsent = ({
|
|
|
138
921
|
|
|
139
922
|
const acceptDisabled = isSubmitting || !areAllRequiredElementsChecked;
|
|
140
923
|
|
|
924
|
+
// =============================================================================
|
|
925
|
+
// EFFECTS & LIFECYCLE
|
|
926
|
+
// =============================================================================
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Reset component state when accessToken changes to prevent stale data
|
|
930
|
+
*/
|
|
931
|
+
useEffect(() => {
|
|
932
|
+
// Clear API cache to prevent data from previous user sessions
|
|
933
|
+
clearApiCache();
|
|
934
|
+
|
|
935
|
+
// Reset request flag when token changes
|
|
936
|
+
isRequestInProgressRef.current = false;
|
|
937
|
+
|
|
938
|
+
// Clear component state when token changes
|
|
939
|
+
setContent(null);
|
|
940
|
+
setCategorizedPurposes(null);
|
|
941
|
+
setSelectedPurposes({});
|
|
942
|
+
setSelectedDataElements({});
|
|
943
|
+
setCollapsedPurposes({});
|
|
944
|
+
setHasAlreadyConsented(false);
|
|
945
|
+
setErrorMessage(null);
|
|
946
|
+
setFetchError(null);
|
|
947
|
+
setIsLoading(true);
|
|
948
|
+
|
|
949
|
+
// Clear age verification and guardian form state
|
|
950
|
+
setShowAgeVerification(false);
|
|
951
|
+
setShowGuardianForm(false);
|
|
952
|
+
setIsSubmittingGuardian(false);
|
|
953
|
+
setGuardianFormData({
|
|
954
|
+
guardianName: "",
|
|
955
|
+
guardianContact: "",
|
|
956
|
+
guardianRelationship: "",
|
|
957
|
+
});
|
|
958
|
+
setGuardianFormErrors({});
|
|
959
|
+
}, [accessToken]);
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Handle automatic acceptance if user has already consented
|
|
963
|
+
*/
|
|
141
964
|
useEffect(() => {
|
|
142
965
|
if (hasAlreadyConsented) {
|
|
143
966
|
onAccept?.();
|
|
@@ -151,7 +974,8 @@ export const RedactoNoticeConsent = ({
|
|
|
151
974
|
): string => {
|
|
152
975
|
if (!content) return defaultText;
|
|
153
976
|
|
|
154
|
-
|
|
977
|
+
// If English is selected, use the base content from active_config
|
|
978
|
+
if (selectedLanguage === "English" || selectedLanguage === "en") {
|
|
155
979
|
if (
|
|
156
980
|
key === "privacy_policy_anchor_text" &&
|
|
157
981
|
content.privacy_policy_anchor_text
|
|
@@ -161,6 +985,7 @@ export const RedactoNoticeConsent = ({
|
|
|
161
985
|
return defaultText;
|
|
162
986
|
}
|
|
163
987
|
|
|
988
|
+
// For other languages, check supported_languages_and_translations
|
|
164
989
|
if (!content.supported_languages_and_translations) {
|
|
165
990
|
return defaultText;
|
|
166
991
|
}
|
|
@@ -190,9 +1015,33 @@ export const RedactoNoticeConsent = ({
|
|
|
190
1015
|
return defaultText;
|
|
191
1016
|
};
|
|
192
1017
|
|
|
1018
|
+
// =============================================================================
|
|
1019
|
+
// EVENT HANDLERS
|
|
1020
|
+
// =============================================================================
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Fetches consent notice data from the API
|
|
1024
|
+
*/
|
|
193
1025
|
const fetchNotice = async () => {
|
|
1026
|
+
// Prevent rapid successive calls
|
|
1027
|
+
if (isRequestInProgressRef.current) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
isRequestInProgressRef.current = true;
|
|
1031
|
+
|
|
1032
|
+
// Cancel any existing request
|
|
1033
|
+
if (abortControllerRef.current) {
|
|
1034
|
+
abortControllerRef.current.abort();
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Create new abort controller for this request
|
|
1038
|
+
abortControllerRef.current = new AbortController();
|
|
1039
|
+
|
|
194
1040
|
setIsLoading(true);
|
|
195
1041
|
setFetchError(null);
|
|
1042
|
+
// Reset age verification and guardian form states when starting a new fetch
|
|
1043
|
+
setShowAgeVerification(false);
|
|
1044
|
+
setShowGuardianForm(false);
|
|
196
1045
|
try {
|
|
197
1046
|
const consentContentData = await fetchConsentContent({
|
|
198
1047
|
noticeId,
|
|
@@ -201,11 +1050,16 @@ export const RedactoNoticeConsent = ({
|
|
|
201
1050
|
baseUrl,
|
|
202
1051
|
language,
|
|
203
1052
|
specific_uuid: applicationId,
|
|
1053
|
+
check_mode: checkMode,
|
|
1054
|
+
signal: abortControllerRef.current?.signal,
|
|
204
1055
|
});
|
|
205
1056
|
setContent(consentContentData.detail.active_config);
|
|
206
1057
|
if (consentContentData.detail.active_config.default_language) {
|
|
1058
|
+
// Normalize "en" to "English" for consistency
|
|
1059
|
+
const defaultLang =
|
|
1060
|
+
consentContentData.detail.active_config.default_language;
|
|
207
1061
|
setSelectedLanguage(
|
|
208
|
-
|
|
1062
|
+
defaultLang === "en" || defaultLang === "EN" ? "English" : defaultLang
|
|
209
1063
|
);
|
|
210
1064
|
}
|
|
211
1065
|
const initialCollapsedState =
|
|
@@ -218,14 +1072,52 @@ export const RedactoNoticeConsent = ({
|
|
|
218
1072
|
);
|
|
219
1073
|
setCollapsedPurposes(initialCollapsedState);
|
|
220
1074
|
|
|
221
|
-
// Initialize selected states
|
|
1075
|
+
// Initialize selected states - handle reconsent pre-population
|
|
1076
|
+
const isReconsentRequired = consentContentData.detail.reconsent_required;
|
|
1077
|
+
const purposeSelections = consentContentData.detail.purpose_selections;
|
|
1078
|
+
|
|
1079
|
+
// Categorize purposes for reconsent UI
|
|
1080
|
+
const newCategorizedPurposes = isReconsentRequired
|
|
1081
|
+
? {
|
|
1082
|
+
alreadyConsented:
|
|
1083
|
+
consentContentData.detail.active_config.purposes.filter(
|
|
1084
|
+
(purpose) => {
|
|
1085
|
+
const selection = purposeSelections?.[purpose.uuid];
|
|
1086
|
+
return (
|
|
1087
|
+
selection &&
|
|
1088
|
+
(selection.status === "ACTIVE" ||
|
|
1089
|
+
selection.status === "EXPIRED") &&
|
|
1090
|
+
!selection.needs_reconsent
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
),
|
|
1094
|
+
needsConsent:
|
|
1095
|
+
consentContentData.detail.active_config.purposes.filter(
|
|
1096
|
+
(purpose) => {
|
|
1097
|
+
const selection = purposeSelections?.[purpose.uuid];
|
|
1098
|
+
return (
|
|
1099
|
+
!selection ||
|
|
1100
|
+
selection.status !== "ACTIVE" ||
|
|
1101
|
+
selection.needs_reconsent
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
),
|
|
1105
|
+
}
|
|
1106
|
+
: null;
|
|
1107
|
+
|
|
1108
|
+
setCategorizedPurposes(newCategorizedPurposes);
|
|
1109
|
+
|
|
222
1110
|
const initialPurposeState: Record<string, boolean> =
|
|
223
1111
|
consentContentData.detail.active_config.purposes.reduce(
|
|
224
1112
|
(acc: Record<string, boolean>, purpose) => {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
[purpose.uuid]
|
|
228
|
-
}
|
|
1113
|
+
if (isReconsentRequired && purposeSelections?.[purpose.uuid]) {
|
|
1114
|
+
// Pre-populate with existing selection for reconsent
|
|
1115
|
+
acc[purpose.uuid] = purposeSelections[purpose.uuid].selected;
|
|
1116
|
+
} else {
|
|
1117
|
+
// Default to false for new consent
|
|
1118
|
+
acc[purpose.uuid] = false;
|
|
1119
|
+
}
|
|
1120
|
+
return acc;
|
|
229
1121
|
},
|
|
230
1122
|
{}
|
|
231
1123
|
);
|
|
@@ -236,29 +1128,53 @@ export const RedactoNoticeConsent = ({
|
|
|
236
1128
|
(acc: Record<string, boolean>, purpose) => {
|
|
237
1129
|
purpose.data_elements.forEach((element) => {
|
|
238
1130
|
const combinedId = `${purpose.uuid}-${element.uuid}`;
|
|
239
|
-
|
|
1131
|
+
if (
|
|
1132
|
+
isReconsentRequired &&
|
|
1133
|
+
purposeSelections?.[purpose.uuid]?.data_elements?.[element.uuid]
|
|
1134
|
+
) {
|
|
1135
|
+
// Pre-populate with existing selection for reconsent
|
|
1136
|
+
acc[combinedId] =
|
|
1137
|
+
purposeSelections[purpose.uuid].data_elements[
|
|
1138
|
+
element.uuid
|
|
1139
|
+
].selected;
|
|
1140
|
+
} else {
|
|
1141
|
+
// Default to false for new consent (including required elements)
|
|
1142
|
+
acc[combinedId] = false;
|
|
1143
|
+
}
|
|
240
1144
|
});
|
|
241
1145
|
return acc;
|
|
242
1146
|
},
|
|
243
1147
|
{}
|
|
244
1148
|
);
|
|
245
1149
|
setSelectedDataElements(initialDataElementState);
|
|
1150
|
+
|
|
1151
|
+
// Check if age verification is required - set this after all content is loaded
|
|
1152
|
+
// but before setIsLoading(false) to ensure proper render order
|
|
1153
|
+
if (consentContentData.detail.is_minor) {
|
|
1154
|
+
setShowAgeVerification(true);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Set loading to false after all state updates are complete
|
|
1158
|
+
setIsLoading(false);
|
|
246
1159
|
} catch (err: unknown) {
|
|
1160
|
+
// Don't treat AbortError as an actual error - it's expected when requests are cancelled
|
|
1161
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
1162
|
+
console.log("Request was cancelled");
|
|
1163
|
+
return; // Don't update state for cancelled requests
|
|
1164
|
+
}
|
|
1165
|
+
|
|
247
1166
|
console.error(err);
|
|
248
1167
|
const error = err as Error & { status?: number };
|
|
249
1168
|
setFetchError(error);
|
|
250
|
-
if (error.status ===
|
|
251
|
-
setRefreshAttempts((c) => c + 1);
|
|
252
|
-
setIsRefreshingToken(true);
|
|
253
|
-
onError?.(error);
|
|
254
|
-
} else if (error.status === 409) {
|
|
1169
|
+
if (error.status === 409) {
|
|
255
1170
|
setHasAlreadyConsented(true);
|
|
256
1171
|
} else {
|
|
257
1172
|
onError?.(error);
|
|
258
1173
|
}
|
|
259
|
-
|
|
1174
|
+
// Ensure loading is set to false even on error
|
|
260
1175
|
setIsLoading(false);
|
|
261
|
-
|
|
1176
|
+
} finally {
|
|
1177
|
+
isRequestInProgressRef.current = false; // Reset request flag
|
|
262
1178
|
}
|
|
263
1179
|
};
|
|
264
1180
|
|
|
@@ -271,9 +1187,10 @@ export const RedactoNoticeConsent = ({
|
|
|
271
1187
|
accessToken,
|
|
272
1188
|
refreshToken,
|
|
273
1189
|
language,
|
|
274
|
-
onError,
|
|
275
1190
|
applicationId,
|
|
276
|
-
|
|
1191
|
+
checkMode,
|
|
1192
|
+
// Removed onError and isRefreshingToken from dependencies to prevent duplicate API calls
|
|
1193
|
+
// onError is only called on errors, not needed for initial data fetching
|
|
277
1194
|
]);
|
|
278
1195
|
|
|
279
1196
|
const togglePurposeCollapse = (purposeUuid: string) => {
|
|
@@ -320,6 +1237,9 @@ export const RedactoNoticeConsent = ({
|
|
|
320
1237
|
elementUuid: string,
|
|
321
1238
|
purposeUuid: string
|
|
322
1239
|
) => {
|
|
1240
|
+
// Prevent state updates if content is not loaded yet
|
|
1241
|
+
if (!content) return;
|
|
1242
|
+
|
|
323
1243
|
const combinedId = `${purposeUuid}-${elementUuid}`;
|
|
324
1244
|
|
|
325
1245
|
// Update data element state
|
|
@@ -330,21 +1250,19 @@ export const RedactoNoticeConsent = ({
|
|
|
330
1250
|
};
|
|
331
1251
|
|
|
332
1252
|
// Check if all required elements for this purpose are checked
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
1253
|
+
const purpose = content.purposes.find((p) => p.uuid === purposeUuid);
|
|
1254
|
+
if (purpose) {
|
|
1255
|
+
const requiredElements = purpose.data_elements.filter(
|
|
1256
|
+
(el) => el.required
|
|
1257
|
+
);
|
|
1258
|
+
const allRequiredElementsChecked = requiredElements.every(
|
|
1259
|
+
(el) => newState[`${purposeUuid}-${el.uuid}`]
|
|
1260
|
+
);
|
|
342
1261
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
1262
|
+
setSelectedPurposes((prevPurposes) => ({
|
|
1263
|
+
...prevPurposes,
|
|
1264
|
+
[purposeUuid]: allRequiredElementsChecked,
|
|
1265
|
+
}));
|
|
348
1266
|
}
|
|
349
1267
|
|
|
350
1268
|
return newState;
|
|
@@ -352,6 +1270,9 @@ export const RedactoNoticeConsent = ({
|
|
|
352
1270
|
};
|
|
353
1271
|
|
|
354
1272
|
const handleAccept = async () => {
|
|
1273
|
+
// Create abort controller for submission
|
|
1274
|
+
const submitController = new AbortController();
|
|
1275
|
+
|
|
355
1276
|
try {
|
|
356
1277
|
setIsSubmitting(true);
|
|
357
1278
|
setErrorMessage(null);
|
|
@@ -375,10 +1296,17 @@ export const RedactoNoticeConsent = ({
|
|
|
375
1296
|
meta_data: applicationId
|
|
376
1297
|
? { specific_uuid: applicationId }
|
|
377
1298
|
: undefined,
|
|
1299
|
+
signal: submitController.signal,
|
|
378
1300
|
});
|
|
379
1301
|
}
|
|
380
1302
|
onAccept?.();
|
|
381
1303
|
} catch (error) {
|
|
1304
|
+
// Don't treat AbortError as an actual error
|
|
1305
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
1306
|
+
console.log("Consent submission was cancelled");
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
382
1310
|
console.error("Error submitting consent:", error);
|
|
383
1311
|
const err = error as Error & { status?: number };
|
|
384
1312
|
if (err.status === 500) {
|
|
@@ -392,7 +1320,7 @@ export const RedactoNoticeConsent = ({
|
|
|
392
1320
|
}
|
|
393
1321
|
};
|
|
394
1322
|
|
|
395
|
-
const handleDecline = async () => {
|
|
1323
|
+
const handleDecline = useCallback(async () => {
|
|
396
1324
|
// try {
|
|
397
1325
|
// setIsSubmitting(true);
|
|
398
1326
|
// if (content) {
|
|
@@ -418,19 +1346,319 @@ export const RedactoNoticeConsent = ({
|
|
|
418
1346
|
// setIsSubmitting(false);
|
|
419
1347
|
// }
|
|
420
1348
|
onDecline?.();
|
|
421
|
-
};
|
|
1349
|
+
}, [onDecline]);
|
|
422
1350
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
1351
|
+
// Guardian form handlers
|
|
1352
|
+
const handleGuardianFormChange = useCallback(
|
|
1353
|
+
(field: string, value: string) => {
|
|
1354
|
+
setGuardianFormData((prev) => ({
|
|
1355
|
+
...prev,
|
|
1356
|
+
[field]: value,
|
|
1357
|
+
}));
|
|
1358
|
+
// Clear error when user starts typing
|
|
1359
|
+
if (guardianFormErrors[field]) {
|
|
1360
|
+
setGuardianFormErrors((prev) => ({
|
|
1361
|
+
...prev,
|
|
1362
|
+
[field]: "",
|
|
1363
|
+
}));
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
[guardianFormErrors]
|
|
432
1367
|
);
|
|
433
1368
|
|
|
1369
|
+
const validateGuardianForm = () => {
|
|
1370
|
+
const errors: Record<string, string> = {};
|
|
1371
|
+
|
|
1372
|
+
if (!guardianFormData.guardianName.trim()) {
|
|
1373
|
+
errors.guardianName = "Guardian name is required";
|
|
1374
|
+
}
|
|
1375
|
+
if (!guardianFormData.guardianContact.trim()) {
|
|
1376
|
+
errors.guardianContact = "Guardian contact is required";
|
|
1377
|
+
}
|
|
1378
|
+
if (!guardianFormData.guardianRelationship.trim()) {
|
|
1379
|
+
errors.guardianRelationship = "Relationship to guardian is required";
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
setGuardianFormErrors(errors);
|
|
1383
|
+
return Object.keys(errors).length === 0;
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
const handleGuardianFormNext = useCallback(async () => {
|
|
1387
|
+
if (!validateGuardianForm()) {
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Create abort controller for submission
|
|
1392
|
+
const guardianController = new AbortController();
|
|
1393
|
+
|
|
1394
|
+
try {
|
|
1395
|
+
setIsSubmittingGuardian(true);
|
|
1396
|
+
setGuardianFormErrors({}); // Clear any previous errors
|
|
1397
|
+
|
|
1398
|
+
await submitGuardianInfo({
|
|
1399
|
+
accessToken,
|
|
1400
|
+
baseUrl,
|
|
1401
|
+
guardianName: guardianFormData.guardianName,
|
|
1402
|
+
guardianContact: guardianFormData.guardianContact,
|
|
1403
|
+
guardianRelationship: guardianFormData.guardianRelationship,
|
|
1404
|
+
signal: guardianController.signal,
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
// Success - proceed to consent form
|
|
1408
|
+
setShowGuardianForm(false);
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
// Handle API errors
|
|
1411
|
+
const err = error as Error & { status?: number };
|
|
1412
|
+
|
|
1413
|
+
if (err.status === 422) {
|
|
1414
|
+
// Validation error from server - try to extract field-specific errors
|
|
1415
|
+
if (err.message.includes("guardian_contact")) {
|
|
1416
|
+
setGuardianFormErrors({ guardianContact: err.message });
|
|
1417
|
+
} else if (err.message.includes("guardian_name")) {
|
|
1418
|
+
setGuardianFormErrors({ guardianName: err.message });
|
|
1419
|
+
} else if (err.message.includes("guardian_relationship")) {
|
|
1420
|
+
setGuardianFormErrors({ guardianRelationship: err.message });
|
|
1421
|
+
} else {
|
|
1422
|
+
setGuardianFormErrors({ general: err.message });
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
// Other errors - show general error
|
|
1426
|
+
setGuardianFormErrors({
|
|
1427
|
+
general:
|
|
1428
|
+
err.message ||
|
|
1429
|
+
"Failed to submit guardian information. Please try again.",
|
|
1430
|
+
});
|
|
1431
|
+
onError?.(err);
|
|
1432
|
+
}
|
|
1433
|
+
} finally {
|
|
1434
|
+
setIsSubmittingGuardian(false);
|
|
1435
|
+
}
|
|
1436
|
+
}, [accessToken, baseUrl, guardianFormData, onError]);
|
|
1437
|
+
|
|
1438
|
+
// Age verification handlers
|
|
1439
|
+
const handleAgeVerificationYes = useCallback(() => {
|
|
1440
|
+
setShowAgeVerification(false);
|
|
1441
|
+
// User is 18+, proceed directly to consent
|
|
1442
|
+
}, []);
|
|
1443
|
+
|
|
1444
|
+
const handleAgeVerificationNo = useCallback(() => {
|
|
1445
|
+
setShowAgeVerification(false);
|
|
1446
|
+
setShowGuardianForm(true);
|
|
1447
|
+
// User is under 18, show guardian form
|
|
1448
|
+
}, []);
|
|
1449
|
+
|
|
1450
|
+
const availableLanguages = useMemo(() => {
|
|
1451
|
+
if (!content) return [language];
|
|
1452
|
+
|
|
1453
|
+
// English is always available since it's in active_config
|
|
1454
|
+
const languages = ["English"];
|
|
1455
|
+
|
|
1456
|
+
// Add default language if it's not English
|
|
1457
|
+
if (
|
|
1458
|
+
content.default_language &&
|
|
1459
|
+
content.default_language !== "English" &&
|
|
1460
|
+
content.default_language !== "en"
|
|
1461
|
+
) {
|
|
1462
|
+
languages.push(content.default_language);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Add all supported languages from translations
|
|
1466
|
+
if (content.supported_languages_and_translations) {
|
|
1467
|
+
Object.keys(content.supported_languages_and_translations).forEach(
|
|
1468
|
+
(lang) => {
|
|
1469
|
+
if (!languages.includes(lang)) {
|
|
1470
|
+
languages.push(lang);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
return languages;
|
|
1477
|
+
}, [content, language]);
|
|
1478
|
+
|
|
1479
|
+
// =============================================================================
|
|
1480
|
+
// TEXT-TO-SPEECH FUNCTIONALITY
|
|
1481
|
+
// =============================================================================
|
|
1482
|
+
|
|
1483
|
+
// Create text segments for TTS in reading order
|
|
1484
|
+
const textSegments = useMemo<TextSegment[]>(() => {
|
|
1485
|
+
if (!content) return [];
|
|
1486
|
+
|
|
1487
|
+
const segments: TextSegment[] = [];
|
|
1488
|
+
|
|
1489
|
+
// 1. Notice banner heading
|
|
1490
|
+
const noticeHeading = getTranslatedText(
|
|
1491
|
+
"notice_banner_heading",
|
|
1492
|
+
content.notice_banner_heading || "Privacy Notice"
|
|
1493
|
+
);
|
|
1494
|
+
if (noticeHeading) {
|
|
1495
|
+
segments.push({
|
|
1496
|
+
text: noticeHeading,
|
|
1497
|
+
elementId: "privacy-notice-title",
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// 2. Notice text
|
|
1502
|
+
const noticeText = getTranslatedText("notice_text", content.notice_text);
|
|
1503
|
+
if (noticeText) {
|
|
1504
|
+
segments.push({
|
|
1505
|
+
text: noticeText,
|
|
1506
|
+
elementId: "privacy-notice-text",
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// 3. Privacy policy text
|
|
1511
|
+
const privacyPrefix = getTranslatedText(
|
|
1512
|
+
"privacy_policy_prefix_text",
|
|
1513
|
+
content.privacy_policy_prefix_text || ""
|
|
1514
|
+
);
|
|
1515
|
+
const privacyAnchor = getTranslatedText(
|
|
1516
|
+
"privacy_policy_anchor_text",
|
|
1517
|
+
content.privacy_policy_anchor_text || "Privacy Policy"
|
|
1518
|
+
);
|
|
1519
|
+
if (privacyPrefix || privacyAnchor) {
|
|
1520
|
+
const privacyText = `${privacyPrefix} ${privacyAnchor}`.trim();
|
|
1521
|
+
if (privacyText) {
|
|
1522
|
+
segments.push({
|
|
1523
|
+
text: privacyText,
|
|
1524
|
+
elementId: "privacy-policy-text",
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// 4. Purpose section heading
|
|
1530
|
+
const purposeHeading = getTranslatedText(
|
|
1531
|
+
"purpose_section_heading",
|
|
1532
|
+
"Manage What You Share"
|
|
1533
|
+
);
|
|
1534
|
+
if (purposeHeading) {
|
|
1535
|
+
segments.push({
|
|
1536
|
+
text: purposeHeading,
|
|
1537
|
+
elementId: "purpose-section-heading",
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// 5. Purposes and data elements
|
|
1542
|
+
const purposesToRead = categorizedPurposes
|
|
1543
|
+
? [
|
|
1544
|
+
...categorizedPurposes.alreadyConsented,
|
|
1545
|
+
...categorizedPurposes.needsConsent,
|
|
1546
|
+
]
|
|
1547
|
+
: content.purposes || [];
|
|
1548
|
+
|
|
1549
|
+
purposesToRead.forEach((purpose) => {
|
|
1550
|
+
// Purpose name
|
|
1551
|
+
const purposeName = getTranslatedText(
|
|
1552
|
+
`purposes.name`,
|
|
1553
|
+
purpose.name,
|
|
1554
|
+
purpose.uuid
|
|
1555
|
+
);
|
|
1556
|
+
if (purposeName) {
|
|
1557
|
+
segments.push({
|
|
1558
|
+
text: purposeName,
|
|
1559
|
+
elementId: `purpose-name-${purpose.uuid}`,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Purpose description
|
|
1564
|
+
const purposeDescription = getTranslatedText(
|
|
1565
|
+
`purposes.description`,
|
|
1566
|
+
purpose.description,
|
|
1567
|
+
purpose.uuid
|
|
1568
|
+
);
|
|
1569
|
+
if (purposeDescription) {
|
|
1570
|
+
segments.push({
|
|
1571
|
+
text: purposeDescription,
|
|
1572
|
+
elementId: `purpose-description-${purpose.uuid}`,
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Data elements
|
|
1577
|
+
if (purpose.data_elements && purpose.data_elements.length > 0) {
|
|
1578
|
+
purpose.data_elements.forEach((dataElement) => {
|
|
1579
|
+
const elementName = getTranslatedText(
|
|
1580
|
+
`data_elements.name`,
|
|
1581
|
+
dataElement.name,
|
|
1582
|
+
dataElement.uuid
|
|
1583
|
+
);
|
|
1584
|
+
if (elementName) {
|
|
1585
|
+
const requiredText = dataElement.required ? " (required)" : "";
|
|
1586
|
+
segments.push({
|
|
1587
|
+
text: `${elementName}${requiredText}`,
|
|
1588
|
+
elementId: `data-element-${purpose.uuid}-${dataElement.uuid}`,
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
return segments;
|
|
1596
|
+
}, [content, categorizedPurposes, getTranslatedText]);
|
|
1597
|
+
|
|
1598
|
+
// Initialize TTS hook
|
|
1599
|
+
const tts = useTextToSpeech(textSegments, selectedLanguage);
|
|
1600
|
+
|
|
1601
|
+
// Auto-expand collapsed purposes when TTS is reading their content
|
|
1602
|
+
// This ensures accessibility - users can see what's being read even if sections were collapsed
|
|
1603
|
+
useEffect(() => {
|
|
1604
|
+
if (!tts.isPlaying || !content || textSegments.length === 0) return;
|
|
1605
|
+
|
|
1606
|
+
const currentSegment = textSegments[tts.currentIndex];
|
|
1607
|
+
if (!currentSegment) return;
|
|
1608
|
+
|
|
1609
|
+
// Extract purpose UUID from element ID
|
|
1610
|
+
let purposeUuid: string | null = null;
|
|
1611
|
+
|
|
1612
|
+
// Check if current segment is a data element (format: data-element-{purposeUuid}-{elementUuid})
|
|
1613
|
+
const dataElementMatch = currentSegment.elementId.match(
|
|
1614
|
+
/^data-element-(.+?)-(.+)$/
|
|
1615
|
+
);
|
|
1616
|
+
if (dataElementMatch) {
|
|
1617
|
+
purposeUuid = dataElementMatch[1];
|
|
1618
|
+
} else {
|
|
1619
|
+
// Check if current segment is a purpose name or description (format: purpose-name-{purposeUuid} or purpose-description-{purposeUuid})
|
|
1620
|
+
const purposeNameMatch =
|
|
1621
|
+
currentSegment.elementId.match(/^purpose-name-(.+)$/);
|
|
1622
|
+
const purposeDescMatch = currentSegment.elementId.match(
|
|
1623
|
+
/^purpose-description-(.+)$/
|
|
1624
|
+
);
|
|
1625
|
+
if (purposeNameMatch) {
|
|
1626
|
+
purposeUuid = purposeNameMatch[1];
|
|
1627
|
+
} else if (purposeDescMatch) {
|
|
1628
|
+
purposeUuid = purposeDescMatch[1];
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Expand the purpose if it's collapsed
|
|
1633
|
+
if (purposeUuid && collapsedPurposes[purposeUuid]) {
|
|
1634
|
+
setCollapsedPurposes((prev) => ({
|
|
1635
|
+
...prev,
|
|
1636
|
+
[purposeUuid!]: false,
|
|
1637
|
+
}));
|
|
1638
|
+
}
|
|
1639
|
+
}, [
|
|
1640
|
+
tts.isPlaying,
|
|
1641
|
+
tts.currentIndex,
|
|
1642
|
+
textSegments,
|
|
1643
|
+
content,
|
|
1644
|
+
collapsedPurposes,
|
|
1645
|
+
]);
|
|
1646
|
+
|
|
1647
|
+
// Stop TTS when language changes
|
|
1648
|
+
useEffect(() => {
|
|
1649
|
+
tts.stop();
|
|
1650
|
+
}, [selectedLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1651
|
+
|
|
1652
|
+
// Stop TTS when content changes
|
|
1653
|
+
useEffect(() => {
|
|
1654
|
+
tts.stop();
|
|
1655
|
+
}, [content]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1656
|
+
|
|
1657
|
+
// Handle TTS button click
|
|
1658
|
+
const handleTTSButtonClick = useCallback(() => {
|
|
1659
|
+
tts.toggle();
|
|
1660
|
+
}, [tts]);
|
|
1661
|
+
|
|
434
1662
|
const modalStyle = {
|
|
435
1663
|
borderRadius: settings?.borderRadius || "8px",
|
|
436
1664
|
backgroundColor: settings?.backgroundColor || "#ffffff",
|
|
@@ -475,18 +1703,6 @@ export const RedactoNoticeConsent = ({
|
|
|
475
1703
|
fontWeight: 600,
|
|
476
1704
|
};
|
|
477
1705
|
|
|
478
|
-
const headingStyle = {
|
|
479
|
-
color: settings?.headingColor || "#101828",
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
const textStyle = {
|
|
483
|
-
color: settings?.textColor || "#344054",
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
const sectionStyle = {
|
|
487
|
-
backgroundColor: settings?.backgroundColor || "#ffffff",
|
|
488
|
-
};
|
|
489
|
-
|
|
490
1706
|
const handleCloseError = () => {
|
|
491
1707
|
setErrorMessage(null);
|
|
492
1708
|
};
|
|
@@ -542,6 +1758,20 @@ export const RedactoNoticeConsent = ({
|
|
|
542
1758
|
};
|
|
543
1759
|
}, [isLoading, handleDecline]);
|
|
544
1760
|
|
|
1761
|
+
// Cleanup effect - cancel any pending requests when component unmounts
|
|
1762
|
+
useEffect(() => {
|
|
1763
|
+
return () => {
|
|
1764
|
+
// Cancel any pending fetch request
|
|
1765
|
+
if (abortControllerRef.current) {
|
|
1766
|
+
abortControllerRef.current.abort();
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
}, []);
|
|
1770
|
+
|
|
1771
|
+
// =============================================================================
|
|
1772
|
+
// RENDER
|
|
1773
|
+
// =============================================================================
|
|
1774
|
+
|
|
545
1775
|
return (
|
|
546
1776
|
<>
|
|
547
1777
|
{hasAlreadyConsented ? null : (
|
|
@@ -566,15 +1796,23 @@ export const RedactoNoticeConsent = ({
|
|
|
566
1796
|
aria-describedby="privacy-notice-description"
|
|
567
1797
|
tabIndex={-1}
|
|
568
1798
|
>
|
|
569
|
-
{isLoading
|
|
570
|
-
<div
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1799
|
+
{isLoading ? (
|
|
1800
|
+
<div
|
|
1801
|
+
role="status"
|
|
1802
|
+
aria-live="polite"
|
|
1803
|
+
aria-label="Loading consent notice"
|
|
1804
|
+
style={styles.loadingContainer}
|
|
1805
|
+
>
|
|
1806
|
+
<div style={styles.loadingSpinner} aria-hidden="true"></div>
|
|
1807
|
+
<p style={{ ...styles.privacyText, ...componentTextStyle }}>
|
|
1808
|
+
Loading...
|
|
574
1809
|
</p>
|
|
575
1810
|
</div>
|
|
576
1811
|
) : fetchError ? (
|
|
577
1812
|
<div
|
|
1813
|
+
role="alert"
|
|
1814
|
+
aria-live="assertive"
|
|
1815
|
+
aria-label="Error loading consent notice"
|
|
578
1816
|
style={{
|
|
579
1817
|
...styles.content,
|
|
580
1818
|
...responsiveStyles.content,
|
|
@@ -674,6 +1912,41 @@ export const RedactoNoticeConsent = ({
|
|
|
674
1912
|
</button>
|
|
675
1913
|
</div>
|
|
676
1914
|
</div>
|
|
1915
|
+
) : showAgeVerification ? (
|
|
1916
|
+
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
1917
|
+
<AgeVerification
|
|
1918
|
+
onYes={handleAgeVerificationYes}
|
|
1919
|
+
onNo={handleAgeVerificationNo}
|
|
1920
|
+
onClose={handleDecline}
|
|
1921
|
+
styles={styles}
|
|
1922
|
+
responsiveStyles={responsiveStyles}
|
|
1923
|
+
componentHeadingStyle={componentHeadingStyle}
|
|
1924
|
+
componentTextStyle={componentTextStyle}
|
|
1925
|
+
acceptButtonStyle={acceptButtonStyle}
|
|
1926
|
+
declineButtonStyle={declineButtonStyle}
|
|
1927
|
+
logoUrl={content?.logo_url || logo}
|
|
1928
|
+
isMobile={isMobile}
|
|
1929
|
+
/>
|
|
1930
|
+
</div>
|
|
1931
|
+
) : showGuardianForm ? (
|
|
1932
|
+
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
1933
|
+
<GuardianForm
|
|
1934
|
+
formData={guardianFormData}
|
|
1935
|
+
errors={guardianFormErrors}
|
|
1936
|
+
isSubmitting={isSubmittingGuardian}
|
|
1937
|
+
onChange={handleGuardianFormChange}
|
|
1938
|
+
onNext={handleGuardianFormNext}
|
|
1939
|
+
onDecline={handleDecline}
|
|
1940
|
+
styles={styles}
|
|
1941
|
+
responsiveStyles={responsiveStyles}
|
|
1942
|
+
settings={settings}
|
|
1943
|
+
componentHeadingStyle={componentHeadingStyle}
|
|
1944
|
+
componentTextStyle={componentTextStyle}
|
|
1945
|
+
acceptButtonStyle={acceptButtonStyle}
|
|
1946
|
+
declineButtonStyle={declineButtonStyle}
|
|
1947
|
+
logoUrl={content?.logo_url || logo}
|
|
1948
|
+
/>
|
|
1949
|
+
</div>
|
|
677
1950
|
) : (
|
|
678
1951
|
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
679
1952
|
{errorMessage && (
|
|
@@ -741,45 +2014,128 @@ export const RedactoNoticeConsent = ({
|
|
|
741
2014
|
style={{
|
|
742
2015
|
...styles.title,
|
|
743
2016
|
...responsiveStyles.title,
|
|
744
|
-
...
|
|
2017
|
+
...componentHeadingStyle,
|
|
745
2018
|
}}
|
|
746
2019
|
>
|
|
747
|
-
|
|
2020
|
+
{getTranslatedText(
|
|
2021
|
+
"notice_banner_heading",
|
|
2022
|
+
content?.notice_banner_heading || "Privacy Notice"
|
|
2023
|
+
)}
|
|
748
2024
|
</h2>
|
|
749
2025
|
</div>
|
|
750
2026
|
<div style={styles.languageSelectorContainer}>
|
|
751
|
-
<
|
|
752
|
-
ref={firstFocusableRef}
|
|
2027
|
+
<div
|
|
753
2028
|
style={{
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
2029
|
+
display: "flex",
|
|
2030
|
+
alignItems: "center",
|
|
2031
|
+
gap: "8px",
|
|
757
2032
|
}}
|
|
758
|
-
onClick={toggleLanguageDropdown}
|
|
759
|
-
aria-expanded={isLanguageDropdownOpen}
|
|
760
|
-
aria-haspopup="listbox"
|
|
761
|
-
aria-controls="language-listbox"
|
|
762
2033
|
>
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
2034
|
+
<div>
|
|
2035
|
+
<button
|
|
2036
|
+
ref={firstFocusableRef}
|
|
2037
|
+
style={{
|
|
2038
|
+
...styles.topRight,
|
|
2039
|
+
...responsiveStyles.topRight,
|
|
2040
|
+
...languageSelectorStyle,
|
|
2041
|
+
}}
|
|
2042
|
+
onClick={toggleLanguageDropdown}
|
|
2043
|
+
aria-expanded={isLanguageDropdownOpen}
|
|
2044
|
+
aria-haspopup="listbox"
|
|
2045
|
+
aria-controls="language-listbox"
|
|
2046
|
+
>
|
|
2047
|
+
{selectedLanguage}
|
|
2048
|
+
<svg
|
|
2049
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
2050
|
+
width="16"
|
|
2051
|
+
height="16"
|
|
2052
|
+
viewBox="0 0 16 16"
|
|
2053
|
+
fill="none"
|
|
2054
|
+
stroke={
|
|
2055
|
+
settings?.button?.language?.textColor ||
|
|
2056
|
+
"currentColor"
|
|
2057
|
+
}
|
|
2058
|
+
strokeWidth="2"
|
|
2059
|
+
strokeLinecap="round"
|
|
2060
|
+
strokeLinejoin="round"
|
|
2061
|
+
style={{ flexShrink: 0, marginLeft: "4px" }}
|
|
2062
|
+
aria-hidden="true"
|
|
2063
|
+
>
|
|
2064
|
+
<path d="M4 6l4 4 4-4" />
|
|
2065
|
+
</svg>
|
|
2066
|
+
</button>
|
|
2067
|
+
</div>
|
|
2068
|
+
{(selectedLanguage === "English" ||
|
|
2069
|
+
selectedLanguage === "en") && (
|
|
2070
|
+
<button
|
|
2071
|
+
onClick={handleTTSButtonClick}
|
|
2072
|
+
style={{
|
|
2073
|
+
...styles.topRight,
|
|
2074
|
+
...responsiveStyles.topRight,
|
|
2075
|
+
...languageSelectorStyle,
|
|
2076
|
+
padding: "3px 9px",
|
|
2077
|
+
opacity: textSegments.length === 0 ? 0.5 : 1,
|
|
2078
|
+
}}
|
|
2079
|
+
aria-label={
|
|
2080
|
+
tts.isPlaying && !tts.isPaused
|
|
2081
|
+
? "Pause text-to-speech"
|
|
2082
|
+
: tts.isPaused
|
|
2083
|
+
? "Resume text-to-speech"
|
|
2084
|
+
: "Start text-to-speech"
|
|
2085
|
+
}
|
|
2086
|
+
title={
|
|
2087
|
+
tts.isPlaying && !tts.isPaused
|
|
2088
|
+
? "Pause Talkback"
|
|
2089
|
+
: tts.isPaused
|
|
2090
|
+
? "Resume Talkback"
|
|
2091
|
+
: "Start Talkback"
|
|
2092
|
+
}
|
|
2093
|
+
disabled={textSegments.length === 0}
|
|
2094
|
+
>
|
|
2095
|
+
{tts.isPlaying && !tts.isPaused ? (
|
|
2096
|
+
// Pause icon
|
|
2097
|
+
<svg
|
|
2098
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
2099
|
+
width="16"
|
|
2100
|
+
height="16"
|
|
2101
|
+
viewBox="0 0 24 24"
|
|
2102
|
+
fill="none"
|
|
2103
|
+
stroke={
|
|
2104
|
+
settings?.button?.language?.textColor ||
|
|
2105
|
+
"currentColor"
|
|
2106
|
+
}
|
|
2107
|
+
strokeWidth="2"
|
|
2108
|
+
strokeLinecap="round"
|
|
2109
|
+
strokeLinejoin="round"
|
|
2110
|
+
aria-hidden="true"
|
|
2111
|
+
>
|
|
2112
|
+
<rect x="6" y="4" width="4" height="16" />
|
|
2113
|
+
<rect x="14" y="4" width="4" height="16" />
|
|
2114
|
+
</svg>
|
|
2115
|
+
) : (
|
|
2116
|
+
// Play/Sound icon
|
|
2117
|
+
<svg
|
|
2118
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
2119
|
+
width="16"
|
|
2120
|
+
height="16"
|
|
2121
|
+
viewBox="0 0 24 24"
|
|
2122
|
+
fill="none"
|
|
2123
|
+
stroke={
|
|
2124
|
+
settings?.button?.language?.textColor ||
|
|
2125
|
+
"currentColor"
|
|
2126
|
+
}
|
|
2127
|
+
strokeWidth="2"
|
|
2128
|
+
strokeLinecap="round"
|
|
2129
|
+
strokeLinejoin="round"
|
|
2130
|
+
aria-hidden="true"
|
|
2131
|
+
>
|
|
2132
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
2133
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
2134
|
+
</svg>
|
|
2135
|
+
)}
|
|
2136
|
+
</button>
|
|
2137
|
+
)}
|
|
2138
|
+
</div>
|
|
783
2139
|
{isLanguageDropdownOpen && (
|
|
784
2140
|
<ul
|
|
785
2141
|
id="language-listbox"
|
|
@@ -825,50 +2181,42 @@ export const RedactoNoticeConsent = ({
|
|
|
825
2181
|
style={{
|
|
826
2182
|
...styles.privacyText,
|
|
827
2183
|
...responsiveStyles.privacyText,
|
|
828
|
-
...
|
|
2184
|
+
...componentTextStyle,
|
|
829
2185
|
}}
|
|
830
2186
|
>
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
2187
|
+
<span id="privacy-notice-text">
|
|
2188
|
+
{content
|
|
2189
|
+
? getTranslatedText("notice_text", content.notice_text)
|
|
2190
|
+
: ""}
|
|
2191
|
+
</span>
|
|
834
2192
|
<br />
|
|
835
|
-
|
|
836
|
-
<a
|
|
837
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
838
|
-
href={content?.privacy_policy_url || "#"}
|
|
839
|
-
target="_blank"
|
|
840
|
-
rel="noopener noreferrer"
|
|
841
|
-
aria-label="Privacy Policy (opens in new tab)"
|
|
842
|
-
>
|
|
843
|
-
[
|
|
844
|
-
{getTranslatedText(
|
|
845
|
-
"privacy_policy_anchor_text",
|
|
846
|
-
content?.privacy_policy_anchor_text || "Privacy Policy"
|
|
847
|
-
)}
|
|
848
|
-
]
|
|
849
|
-
</a>{" "}
|
|
850
|
-
and{" "}
|
|
851
|
-
<a
|
|
852
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
853
|
-
href={content?.sub_processors_url || "#"}
|
|
854
|
-
target="_blank"
|
|
855
|
-
rel="noopener noreferrer"
|
|
856
|
-
aria-label="Vendors List (opens in new tab)"
|
|
857
|
-
>
|
|
858
|
-
[
|
|
2193
|
+
<span id="privacy-policy-text">
|
|
859
2194
|
{getTranslatedText(
|
|
860
|
-
"
|
|
861
|
-
content?.
|
|
862
|
-
)}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
2195
|
+
"privacy_policy_prefix_text",
|
|
2196
|
+
content?.privacy_policy_prefix_text || ""
|
|
2197
|
+
)}{" "}
|
|
2198
|
+
<a
|
|
2199
|
+
id="privacy-policy-link"
|
|
2200
|
+
style={{ ...styles.link, ...linkStyle }}
|
|
2201
|
+
href={content?.privacy_policy_url || "#"}
|
|
2202
|
+
target="_blank"
|
|
2203
|
+
rel="noopener noreferrer"
|
|
2204
|
+
aria-label="Privacy Policy (opens in new tab)"
|
|
2205
|
+
>
|
|
2206
|
+
{getTranslatedText(
|
|
2207
|
+
"privacy_policy_anchor_text",
|
|
2208
|
+
content?.privacy_policy_anchor_text ||
|
|
2209
|
+
"Privacy Policy"
|
|
2210
|
+
)}
|
|
2211
|
+
</a>
|
|
2212
|
+
</span>
|
|
866
2213
|
</p>
|
|
867
2214
|
<h2
|
|
2215
|
+
id="purpose-section-heading"
|
|
868
2216
|
style={{
|
|
869
2217
|
...styles.subTitle,
|
|
870
2218
|
...responsiveStyles.subTitle,
|
|
871
|
-
...
|
|
2219
|
+
...componentHeadingStyle,
|
|
872
2220
|
}}
|
|
873
2221
|
>
|
|
874
2222
|
{getTranslatedText(
|
|
@@ -876,159 +2224,70 @@ export const RedactoNoticeConsent = ({
|
|
|
876
2224
|
"Manage What You Share"
|
|
877
2225
|
)}
|
|
878
2226
|
</h2>
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
...responsiveStyles.optionDescription,
|
|
944
|
-
...textStyle,
|
|
945
|
-
}}
|
|
946
|
-
>
|
|
947
|
-
{getTranslatedText(
|
|
948
|
-
`purposes.description`,
|
|
949
|
-
purpose.description,
|
|
950
|
-
purpose.uuid
|
|
951
|
-
)}
|
|
952
|
-
</h3>
|
|
953
|
-
</div>
|
|
954
|
-
</div>
|
|
955
|
-
<input
|
|
956
|
-
className="redacto-checkbox-large"
|
|
957
|
-
type="checkbox"
|
|
958
|
-
checked={selectedPurposes[purpose.uuid] || false}
|
|
959
|
-
onChange={() =>
|
|
960
|
-
handlePurposeCheckboxChange(purpose.uuid)
|
|
961
|
-
}
|
|
962
|
-
aria-label={`Select all data elements for ${getTranslatedText(
|
|
963
|
-
`purposes.name`,
|
|
964
|
-
purpose.name,
|
|
965
|
-
purpose.uuid
|
|
966
|
-
)}`}
|
|
967
|
-
/>
|
|
968
|
-
</div>
|
|
969
|
-
{!collapsedPurposes[purpose.uuid] &&
|
|
970
|
-
purpose.data_elements &&
|
|
971
|
-
purpose.data_elements.length > 0 && (
|
|
972
|
-
<div
|
|
973
|
-
id={`purpose-${purpose.uuid}`}
|
|
974
|
-
style={styles.dataElementsContainer}
|
|
975
|
-
>
|
|
976
|
-
{purpose.data_elements.map((dataElement) => (
|
|
977
|
-
<div
|
|
978
|
-
key={dataElement.uuid}
|
|
979
|
-
style={styles.dataElementItem}
|
|
980
|
-
>
|
|
981
|
-
<span
|
|
982
|
-
style={{
|
|
983
|
-
...styles.dataElementText,
|
|
984
|
-
...responsiveStyles.dataElementText,
|
|
985
|
-
...textStyle,
|
|
986
|
-
}}
|
|
987
|
-
>
|
|
988
|
-
{getTranslatedText(
|
|
989
|
-
`data_elements.name`,
|
|
990
|
-
dataElement.name,
|
|
991
|
-
dataElement.uuid
|
|
992
|
-
)}
|
|
993
|
-
{dataElement.required && (
|
|
994
|
-
<span
|
|
995
|
-
style={{
|
|
996
|
-
color: "red",
|
|
997
|
-
marginLeft: "4px",
|
|
998
|
-
}}
|
|
999
|
-
aria-label="required"
|
|
1000
|
-
>
|
|
1001
|
-
*
|
|
1002
|
-
</span>
|
|
1003
|
-
)}
|
|
1004
|
-
</span>
|
|
1005
|
-
<input
|
|
1006
|
-
className="redacto-checkbox-small"
|
|
1007
|
-
type="checkbox"
|
|
1008
|
-
checked={
|
|
1009
|
-
selectedDataElements[
|
|
1010
|
-
`${purpose.uuid}-${dataElement.uuid}`
|
|
1011
|
-
] || false
|
|
1012
|
-
}
|
|
1013
|
-
onChange={() =>
|
|
1014
|
-
handleDataElementCheckboxChange(
|
|
1015
|
-
dataElement.uuid,
|
|
1016
|
-
purpose.uuid
|
|
1017
|
-
)
|
|
1018
|
-
}
|
|
1019
|
-
aria-label={`Select ${getTranslatedText(
|
|
1020
|
-
`data_elements.name`,
|
|
1021
|
-
dataElement.name,
|
|
1022
|
-
dataElement.uuid
|
|
1023
|
-
)}${dataElement.required ? " (required)" : ""}`}
|
|
1024
|
-
/>
|
|
1025
|
-
</div>
|
|
1026
|
-
))}
|
|
1027
|
-
</div>
|
|
1028
|
-
)}
|
|
1029
|
-
</div>
|
|
1030
|
-
))}
|
|
1031
|
-
</div>
|
|
2227
|
+
|
|
2228
|
+
{/* Reconsent UI with visual indicators only */}
|
|
2229
|
+
{categorizedPurposes ? (
|
|
2230
|
+
<div style={styles.optionsContainer}>
|
|
2231
|
+
{/* Already Consented Purposes */}
|
|
2232
|
+
{categorizedPurposes.alreadyConsented.map((purpose) => (
|
|
2233
|
+
<PurposeItem
|
|
2234
|
+
key={purpose.uuid}
|
|
2235
|
+
purpose={purpose}
|
|
2236
|
+
selectedPurposes={selectedPurposes}
|
|
2237
|
+
collapsedPurposes={collapsedPurposes}
|
|
2238
|
+
selectedDataElements={selectedDataElements}
|
|
2239
|
+
settings={settings}
|
|
2240
|
+
styles={styles}
|
|
2241
|
+
responsiveStyles={responsiveStyles}
|
|
2242
|
+
onPurposeToggle={handlePurposeCheckboxChange}
|
|
2243
|
+
onPurposeCollapse={togglePurposeCollapse}
|
|
2244
|
+
onDataElementToggle={handleDataElementCheckboxChange}
|
|
2245
|
+
getTranslatedText={getTranslatedText}
|
|
2246
|
+
isAlreadyConsented={true}
|
|
2247
|
+
/>
|
|
2248
|
+
))}
|
|
2249
|
+
|
|
2250
|
+
{/* Need Consent Purposes */}
|
|
2251
|
+
{categorizedPurposes.needsConsent.map((purpose) => (
|
|
2252
|
+
<PurposeItem
|
|
2253
|
+
key={purpose.uuid}
|
|
2254
|
+
purpose={purpose}
|
|
2255
|
+
selectedPurposes={selectedPurposes}
|
|
2256
|
+
collapsedPurposes={collapsedPurposes}
|
|
2257
|
+
selectedDataElements={selectedDataElements}
|
|
2258
|
+
settings={settings}
|
|
2259
|
+
styles={styles}
|
|
2260
|
+
responsiveStyles={responsiveStyles}
|
|
2261
|
+
onPurposeToggle={handlePurposeCheckboxChange}
|
|
2262
|
+
onPurposeCollapse={togglePurposeCollapse}
|
|
2263
|
+
onDataElementToggle={handleDataElementCheckboxChange}
|
|
2264
|
+
getTranslatedText={getTranslatedText}
|
|
2265
|
+
isAlreadyConsented={false}
|
|
2266
|
+
/>
|
|
2267
|
+
))}
|
|
2268
|
+
</div>
|
|
2269
|
+
) : (
|
|
2270
|
+
/* Regular consent UI */
|
|
2271
|
+
<div style={styles.optionsContainer}>
|
|
2272
|
+
{content?.purposes?.map((purpose) => (
|
|
2273
|
+
<PurposeItem
|
|
2274
|
+
key={purpose.uuid}
|
|
2275
|
+
purpose={purpose}
|
|
2276
|
+
selectedPurposes={selectedPurposes}
|
|
2277
|
+
collapsedPurposes={collapsedPurposes}
|
|
2278
|
+
selectedDataElements={selectedDataElements}
|
|
2279
|
+
settings={settings}
|
|
2280
|
+
styles={styles}
|
|
2281
|
+
responsiveStyles={responsiveStyles}
|
|
2282
|
+
onPurposeToggle={handlePurposeCheckboxChange}
|
|
2283
|
+
onPurposeCollapse={togglePurposeCollapse}
|
|
2284
|
+
onDataElementToggle={handleDataElementCheckboxChange}
|
|
2285
|
+
getTranslatedText={getTranslatedText}
|
|
2286
|
+
isAlreadyConsented={false}
|
|
2287
|
+
/>
|
|
2288
|
+
))}
|
|
2289
|
+
</div>
|
|
2290
|
+
)}
|
|
1032
2291
|
</div>
|
|
1033
2292
|
<div
|
|
1034
2293
|
style={{
|