@redacto.io/consent-sdk-react 3.2.0 → 3.4.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.
@@ -5,9 +5,9 @@
5
5
  * collection and reconsent flows with visual distinction between already-consented
6
6
  * and needs-consent purposes.
7
7
  */
8
-
8
+ // @ts-nocheck - React 19 @types/react has Element/ReactNode assignability issues; tsc passes
9
9
  // React imports
10
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
11
 
12
12
  // API imports
13
13
  import {
@@ -29,6 +29,9 @@ import type { Props, TranslationObject } from "./types";
29
29
  // Asset imports
30
30
  import logo from "./assets/redacto-logo.png";
31
31
 
32
+ // Constants
33
+ import { DPO_AUDIO_ELEMENT_MAP } from "./constants";
34
+
32
35
  // Utility imports
33
36
  import { injectCheckboxStyles } from "./injectStyles";
34
37
  import { styles } from "./styles";
@@ -65,284 +68,287 @@ const PurposeItem: React.FC<
65
68
  selectedLanguage,
66
69
  initialDataElementSelections = {},
67
70
  }) => {
68
- const headingStyle = useMemo(
69
- () => ({
70
- color: settings?.headingColor || "#323B4B",
71
- fontFamily: settings?.font || "inherit",
72
- }),
73
- [settings]
74
- );
71
+ const headingStyle = useMemo(
72
+ () => ({
73
+ color: settings?.headingColor || "#323B4B",
74
+ fontFamily: settings?.font || "inherit",
75
+ }),
76
+ [settings]
77
+ );
75
78
 
76
- const textStyle = useMemo(
77
- () => ({
78
- color: settings?.textColor || "#344054",
79
- fontFamily: settings?.font || "inherit",
80
- }),
81
- [settings]
82
- );
79
+ const textStyle = useMemo(
80
+ () => ({
81
+ color: settings?.textColor || "#344054",
82
+ fontFamily: settings?.font || "inherit",
83
+ }),
84
+ [settings]
85
+ );
83
86
 
84
- return (
85
- <div key={purpose.uuid}>
86
- <div style={styles.optionItem}>
87
- <div
88
- style={styles.optionLeft}
89
- onClick={() => onPurposeCollapse(purpose.uuid)}
90
- role="button"
91
- tabIndex={0}
92
- aria-expanded={!collapsedPurposes[purpose.uuid]}
93
- aria-controls={`purpose-${purpose.uuid}`}
94
- aria-label={`${collapsedPurposes[purpose.uuid] ? "Expand" : "Collapse"} ${getTranslatedText(
95
- `purposes.name`,
96
- purpose.name,
97
- purpose.uuid
98
- )} details`}
99
- onKeyDown={(e) => {
100
- if (e.key === "Enter" || e.key === " ") {
101
- e.preventDefault();
102
- onPurposeCollapse(purpose.uuid);
103
- }
104
- }}
105
- >
87
+ return (
88
+ <div key={purpose.uuid}>
89
+ <div style={styles.optionItem}>
106
90
  <div
107
- style={{
108
- display: "flex",
109
- alignItems: "center",
110
- justifyContent: "center",
111
- marginLeft: "5px",
91
+ style={styles.optionLeft}
92
+ onClick={() => onPurposeCollapse(purpose.uuid)}
93
+ role="button"
94
+ tabIndex={0}
95
+ aria-expanded={!collapsedPurposes[purpose.uuid]}
96
+ aria-controls={`purpose-${purpose.uuid}`}
97
+ aria-label={`${collapsedPurposes[purpose.uuid] ? "Expand" : "Collapse"} ${getTranslatedText(
98
+ `purposes.name`,
99
+ purpose.name,
100
+ purpose.uuid
101
+ )} details`}
102
+ onKeyDown={(e) => {
103
+ if (e.key === "Enter" || e.key === " ") {
104
+ e.preventDefault();
105
+ onPurposeCollapse(purpose.uuid);
106
+ }
112
107
  }}
113
108
  >
114
- <svg
115
- width="7"
116
- height="12"
117
- viewBox="0 0 7 12"
118
- fill="none"
119
- xmlns="http://www.w3.org/2000/svg"
120
- stroke={settings?.headingColor || "#323B4B"}
121
- strokeWidth="2"
122
- strokeLinecap="round"
123
- strokeLinejoin="round"
109
+ <div
124
110
  style={{
125
- transform: !collapsedPurposes[purpose.uuid]
126
- ? "rotate(90deg)"
127
- : "rotate(0deg)",
128
- transition: "transform 0.3s ease",
111
+ display: "flex",
112
+ alignItems: "center",
113
+ justifyContent: "center",
114
+ marginLeft: "5px",
129
115
  }}
130
- aria-hidden="true"
131
116
  >
132
- <path d="M1 1L6 6L1 11" />
133
- </svg>
117
+ <svg
118
+ width="7"
119
+ height="12"
120
+ viewBox="0 0 7 12"
121
+ fill="none"
122
+ xmlns="http://www.w3.org/2000/svg"
123
+ stroke={settings?.headingColor || "#323B4B"}
124
+ strokeWidth="2"
125
+ strokeLinecap={"round" as const}
126
+ strokeLinejoin={"round" as const}
127
+ style={{
128
+ transform: !collapsedPurposes[purpose.uuid]
129
+ ? "rotate(90deg)"
130
+ : "rotate(0deg)",
131
+ transition: "transform 0.3s ease",
132
+ }}
133
+ aria-hidden="true"
134
+ >
135
+ <path d="M1 1L6 6L1 11" />
136
+ </svg>
137
+ </div>
138
+ <div style={styles.optionTextContainer}>
139
+ <h2
140
+ key={`purpose-name-${purpose.uuid}-${selectedLanguage}`}
141
+ id={`purpose-name-${purpose.uuid}`}
142
+ style={{
143
+ ...styles.optionTitle,
144
+ ...responsiveStyles.optionTitle,
145
+ ...headingStyle,
146
+ }}
147
+ >
148
+ {getTranslatedText(`purposes.name`, purpose.name, purpose.uuid)}
149
+ </h2>
150
+ <h3
151
+ key={`purpose-description-${purpose.uuid}-${selectedLanguage}`}
152
+ id={`purpose-description-${purpose.uuid}`}
153
+ style={{
154
+ ...styles.optionDescription,
155
+ ...responsiveStyles.optionDescription,
156
+ ...textStyle,
157
+ }}
158
+ >
159
+ {getTranslatedText(
160
+ `purposes.description`,
161
+ purpose.description,
162
+ purpose.uuid
163
+ )}
164
+ </h3>
165
+ </div>
134
166
  </div>
135
- <div style={styles.optionTextContainer}>
136
- <h2
137
- key={`purpose-name-${purpose.uuid}-${selectedLanguage}`}
138
- id={`purpose-name-${purpose.uuid}`}
139
- style={{
140
- ...styles.optionTitle,
141
- ...responsiveStyles.optionTitle,
142
- ...headingStyle,
143
- }}
144
- >
145
- {getTranslatedText(`purposes.name`, purpose.name, purpose.uuid)}
146
- </h2>
147
- <h3
148
- key={`purpose-description-${purpose.uuid}-${selectedLanguage}`}
149
- id={`purpose-description-${purpose.uuid}`}
167
+ {!isAlreadyConsented && (
168
+ <input
169
+ className="redacto-checkbox-large"
170
+ type="checkbox"
171
+ checked={selectedPurposes[purpose.uuid] || false}
172
+ onChange={() => onPurposeToggle(purpose.uuid)}
173
+ aria-label={`Select all data elements for ${getTranslatedText(
174
+ `purposes.name`,
175
+ purpose.name,
176
+ purpose.uuid
177
+ )}`}
178
+ />
179
+ )}
180
+ {isAlreadyConsented && (
181
+ <div
150
182
  style={{
151
- ...styles.optionDescription,
152
- ...responsiveStyles.optionDescription,
153
- ...textStyle,
183
+ display: "flex",
184
+ alignItems: "center",
185
+ justifyContent: "center",
186
+ width: "20px",
187
+ height: "20px",
188
+ borderRadius: "50%",
189
+ backgroundColor: "#10B981",
190
+ marginLeft: "12px",
154
191
  }}
155
- >
156
- {getTranslatedText(
157
- `purposes.description`,
158
- purpose.description,
192
+ aria-label={`Already consented to ${getTranslatedText(
193
+ `purposes.name`,
194
+ purpose.name,
159
195
  purpose.uuid
160
- )}
161
- </h3>
162
- </div>
163
- </div>
164
- {!isAlreadyConsented && (
165
- <input
166
- className="redacto-checkbox-large"
167
- type="checkbox"
168
- checked={selectedPurposes[purpose.uuid] || false}
169
- onChange={() => onPurposeToggle(purpose.uuid)}
170
- aria-label={`Select all data elements for ${getTranslatedText(
171
- `purposes.name`,
172
- purpose.name,
173
- purpose.uuid
174
- )}`}
175
- />
176
- )}
177
- {isAlreadyConsented && (
178
- <div
179
- style={{
180
- display: "flex",
181
- alignItems: "center",
182
- justifyContent: "center",
183
- width: "20px",
184
- height: "20px",
185
- borderRadius: "50%",
186
- backgroundColor: "#10B981",
187
- marginLeft: "12px",
188
- }}
189
- aria-label={`Already consented to ${getTranslatedText(
190
- `purposes.name`,
191
- purpose.name,
192
- purpose.uuid
193
- )}`}
194
- >
195
- <svg
196
- width="12"
197
- height="12"
198
- viewBox="0 0 12 12"
199
- fill="none"
200
- xmlns="http://www.w3.org/2000/svg"
201
- aria-hidden="true"
196
+ )}`}
202
197
  >
203
- <path
204
- d="M4 6L5.5 7.5L8.5 4.5"
205
- stroke="white"
206
- strokeWidth="2"
207
- strokeLinecap="round"
208
- strokeLinejoin="round"
209
- />
210
- </svg>
211
- </div>
212
- )}
213
- </div>
214
- {!collapsedPurposes[purpose.uuid] &&
215
- purpose.data_elements &&
216
- purpose.data_elements.length > 0 && (
217
- <div
218
- key={`purpose-${purpose.uuid}-container-${selectedLanguage}`}
219
- id={`purpose-${purpose.uuid}`}
220
- style={styles.dataElementsContainer}
221
- >
222
- {purpose.data_elements.map((dataElement: any) => (
223
- <div
224
- key={`${dataElement.uuid}-${selectedLanguage}`}
225
- style={styles.dataElementItem}
198
+ <svg
199
+ width="12"
200
+ height="12"
201
+ viewBox="0 0 12 12"
202
+ fill="none"
203
+ xmlns="http://www.w3.org/2000/svg"
204
+ aria-hidden="true"
226
205
  >
227
- <span
228
- key={`data-element-${purpose.uuid}-${dataElement.uuid}-${selectedLanguage}`}
229
- id={`data-element-${purpose.uuid}-${dataElement.uuid}`}
230
- style={{
231
- ...styles.dataElementText,
232
- ...responsiveStyles.dataElementText,
233
- ...textStyle,
234
- }}
206
+ <path
207
+ d="M4 6L5.5 7.5L8.5 4.5"
208
+ stroke="white"
209
+ strokeWidth="2"
210
+ strokeLinecap="round"
211
+ strokeLinejoin="round"
212
+ />
213
+ </svg>
214
+ </div>
215
+ )}
216
+ </div>
217
+ {!collapsedPurposes[purpose.uuid] &&
218
+ purpose.data_elements &&
219
+ purpose.data_elements.length > 0 && (
220
+ <div
221
+ key={`purpose-${purpose.uuid}-container-${selectedLanguage}`}
222
+ id={`purpose-${purpose.uuid}`}
223
+ style={styles.dataElementsContainer}
224
+ >
225
+ {purpose.data_elements.map((dataElement: any) => (
226
+ <div
227
+ key={`${dataElement.uuid}-${selectedLanguage}`}
228
+ style={styles.dataElementItem}
235
229
  >
236
- {getTranslatedText(
237
- `data_elements.name`,
238
- dataElement.name,
239
- dataElement.uuid
240
- )}
241
- {dataElement.required && (
242
- <span
243
- style={{
244
- color: "red",
245
- marginLeft: "4px",
246
- }}
247
- aria-label="required"
248
- >
249
- *
250
- </span>
251
- )}
252
- </span>
253
- {!isAlreadyConsented && (
254
- <input
255
- className="redacto-checkbox-small"
256
- type="checkbox"
257
- checked={
258
- selectedDataElements[
259
- `${purpose.uuid}-${dataElement.uuid}`
260
- ] || false
261
- }
262
- onChange={() =>
263
- onDataElementToggle(dataElement.uuid, purpose.uuid)
264
- }
265
- aria-label={`Select ${getTranslatedText(
230
+ <span
231
+ key={`data-element-${purpose.uuid}-${dataElement.uuid}-${selectedLanguage}`}
232
+ id={`data-element-${purpose.uuid}-${dataElement.uuid}`}
233
+ style={{
234
+ ...styles.dataElementText,
235
+ ...responsiveStyles.dataElementText,
236
+ ...textStyle,
237
+ }}
238
+ >
239
+ {getTranslatedText(
266
240
  `data_elements.name`,
267
241
  dataElement.name,
268
242
  dataElement.uuid
269
- )}${dataElement.required ? " (required)" : ""}`}
270
- />
271
- )}
272
- {isAlreadyConsented && (
273
- <>
274
- {(() => {
275
- const combinedId = `${purpose.uuid}-${dataElement.uuid}`;
276
- const isCurrentlySelected =
277
- selectedDataElements[combinedId] || false;
278
- const wasOriginallySelected =
279
- initialDataElementSelections[combinedId] || false;
280
-
281
- // Show green tick only if it was originally selected
282
- // Show checkbox if it's newly selected or unselected
283
- if (isCurrentlySelected && wasOriginallySelected) {
284
- return (
285
- <div
286
- style={{
287
- display: "flex",
288
- alignItems: "center",
289
- justifyContent: "center",
290
- width: "16px",
291
- height: "16px",
292
- borderRadius: "50%",
293
- backgroundColor: "#10B981",
294
- }}
295
- aria-label={`Already consented to ${getTranslatedText(
296
- `data_elements.name`,
297
- dataElement.name,
298
- dataElement.uuid
299
- )}`}
300
- >
301
- <svg
302
- width="10"
303
- height="10"
304
- viewBox="0 0 10 10"
305
- fill="none"
306
- xmlns="http://www.w3.org/2000/svg"
307
- aria-hidden="true"
308
- >
309
- <path
310
- d="M3 5L4.5 6.5L7.5 3.5"
311
- stroke="white"
312
- strokeWidth="1.5"
313
- strokeLinecap="round"
314
- strokeLinejoin="round"
315
- />
316
- </svg>
317
- </div>
318
- );
319
- } else {
320
- return (
321
- <input
322
- className="redacto-checkbox-small"
323
- type="checkbox"
324
- checked={isCurrentlySelected}
325
- onChange={() =>
326
- onDataElementToggle(dataElement.uuid, purpose.uuid)
327
- }
328
- aria-label={`Select ${getTranslatedText(
329
- `data_elements.name`,
330
- dataElement.name,
331
- dataElement.uuid
332
- )}${dataElement.required ? " (required)" : ""}`}
333
- />
334
- );
243
+ )}
244
+ {dataElement.required && (
245
+ <span
246
+ style={{
247
+ color: "red",
248
+ marginLeft: "4px",
249
+ }}
250
+ aria-label="required"
251
+ >
252
+ *
253
+ </span>
254
+ )}
255
+ </span>
256
+ {!isAlreadyConsented && (
257
+ <input
258
+ className="redacto-checkbox-small"
259
+ type="checkbox"
260
+ checked={
261
+ selectedDataElements[
262
+ `${purpose.uuid}-${dataElement.uuid}`
263
+ ] || false
335
264
  }
336
- })()}
337
- </>
338
- )}
339
- </div>
340
- ))}
341
- </div>
342
- )}
343
- </div>
344
- );
345
- };
265
+ onChange={() =>
266
+ onDataElementToggle(dataElement.uuid, purpose.uuid)
267
+ }
268
+ aria-label={`Select ${getTranslatedText(
269
+ `data_elements.name`,
270
+ dataElement.name,
271
+ dataElement.uuid
272
+ )}${dataElement.required ? " (required)" : ""}`}
273
+ />
274
+ )}
275
+ {isAlreadyConsented && (
276
+ <>
277
+ {(() => {
278
+ const combinedId = `${purpose.uuid}-${dataElement.uuid}`;
279
+ const wasOriginallySelected =
280
+ initialDataElementSelections[combinedId] || false;
281
+
282
+ // Review mode: read-only display of what user consented to
283
+ // Green tick = consented, empty circle = not consented (no editing)
284
+ if (wasOriginallySelected) {
285
+ return (
286
+ <div
287
+ style={{
288
+ display: "flex",
289
+ alignItems: "center",
290
+ justifyContent: "center",
291
+ width: "16px",
292
+ height: "16px",
293
+ borderRadius: "50%",
294
+ backgroundColor: "#10B981",
295
+ }}
296
+ aria-label={`Consented to ${getTranslatedText(
297
+ `data_elements.name`,
298
+ dataElement.name,
299
+ dataElement.uuid
300
+ )}`}
301
+ >
302
+ <svg
303
+ width="10"
304
+ height="10"
305
+ viewBox="0 0 10 10"
306
+ fill="none"
307
+ xmlns="http://www.w3.org/2000/svg"
308
+ aria-hidden="true"
309
+ >
310
+ <path
311
+ d="M3 5L4.5 6.5L7.5 3.5"
312
+ stroke="white"
313
+ strokeWidth="1.5"
314
+ strokeLinecap={"round" as const}
315
+ strokeLinejoin={"round" as const}
316
+ />
317
+ </svg>
318
+ </div>
319
+ );
320
+ } else {
321
+ // Not consented: show empty circle (read-only, no checkbox)
322
+ return (
323
+ <div
324
+ style={{
325
+ display: "flex",
326
+ alignItems: "center",
327
+ justifyContent: "center",
328
+ width: "16px",
329
+ height: "16px",
330
+ borderRadius: "50%",
331
+ border: "2px solid #d0d5dd",
332
+ backgroundColor: "transparent",
333
+ }}
334
+ aria-label={`Not consented to ${getTranslatedText(
335
+ `data_elements.name`,
336
+ dataElement.name,
337
+ dataElement.uuid
338
+ )}`}
339
+ />
340
+ );
341
+ }
342
+ })()}
343
+ </>
344
+ )}
345
+ </div>
346
+ ))}
347
+ </div>
348
+ )}
349
+ </div>
350
+ );
351
+ };
346
352
 
347
353
  /**
348
354
  * GuardianForm Component
@@ -384,220 +390,220 @@ const GuardianForm: React.FC<{
384
390
  declineButtonStyle,
385
391
  logoUrl,
386
392
  }) => {
387
- const inputStyle = {
388
- width: "100%",
389
- padding: "10px 12px",
390
- border: `1px solid ${settings?.borderColor || "#d0d5dd"}`,
391
- borderRadius: settings?.borderRadius || "8px",
392
- fontSize: "13px",
393
- fontFamily: settings?.font || "inherit",
394
- color: settings?.textColor || "#344054",
395
- backgroundColor: settings?.backgroundColor || "#ffffff",
396
- marginBottom: "4px",
397
- boxSizing: "border-box" as const,
398
- };
393
+ const inputStyle = {
394
+ width: "100%",
395
+ padding: "10px 12px",
396
+ border: `1px solid ${settings?.borderColor || "#d0d5dd"}`,
397
+ borderRadius: settings?.borderRadius || "8px",
398
+ fontSize: "13px",
399
+ fontFamily: settings?.font || "inherit",
400
+ color: settings?.textColor || "#344054",
401
+ backgroundColor: settings?.backgroundColor || "#ffffff",
402
+ marginBottom: "4px",
403
+ boxSizing: "border-box" as const,
404
+ };
399
405
 
400
- const errorStyle = {
401
- color: "#DC2626",
402
- fontSize: "12px",
403
- marginTop: "4px",
404
- marginBottom: "16px",
405
- };
406
+ const errorStyle = {
407
+ color: "#DC2626",
408
+ fontSize: "12px",
409
+ marginTop: "4px",
410
+ marginBottom: "16px",
411
+ };
406
412
 
407
- const labelStyle = {
408
- ...componentTextStyle,
409
- fontSize: "13px",
410
- fontWeight: "500",
411
- marginBottom: "6px",
412
- display: "block",
413
- };
413
+ const labelStyle = {
414
+ ...componentTextStyle,
415
+ fontSize: "13px",
416
+ fontWeight: "500",
417
+ marginBottom: "6px",
418
+ display: "block",
419
+ };
414
420
 
415
- return (
416
- <>
417
- <div style={{ ...styles.topSection, ...componentTextStyle }}>
418
- <div style={styles.topLeft}>
419
- <img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
420
- <h2
421
- id="guardian-form-title"
422
- style={{
423
- ...styles.title,
424
- ...responsiveStyles.title,
425
- ...componentHeadingStyle,
426
- fontSize: "16px",
427
- fontWeight: 600,
428
- }}
429
- >
430
- Guardian Information Required
431
- </h2>
421
+ return (
422
+ <>
423
+ <div style={{ ...styles.topSection, ...componentTextStyle }}>
424
+ <div style={styles.topLeft}>
425
+ <img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
426
+ <h2
427
+ id="guardian-form-title"
428
+ style={{
429
+ ...styles.title,
430
+ ...responsiveStyles.title,
431
+ ...componentHeadingStyle,
432
+ fontSize: "16px",
433
+ fontWeight: 600,
434
+ }}
435
+ >
436
+ Guardian Information Required
437
+ </h2>
438
+ </div>
432
439
  </div>
433
- </div>
434
-
435
- <div
436
- style={{
437
- ...styles.middleSection,
438
- ...responsiveStyles.middleSection,
439
- ...componentTextStyle,
440
- }}
441
- >
442
- <p
443
- style={{
444
- ...styles.privacyText,
445
- ...responsiveStyles.privacyText,
446
- ...componentTextStyle,
447
- fontSize: "14px",
448
- lineHeight: "1.4",
449
- }}
450
- >
451
- As you are under the age of consent, we need to collect information
452
- about your legal guardian to proceed.
453
- </p>
454
440
 
455
441
  <div
456
442
  style={{
457
- display: "flex",
458
- flexDirection: "column",
459
- gap: "12px",
460
- marginTop: "16px",
443
+ ...styles.middleSection,
444
+ ...responsiveStyles.middleSection,
445
+ ...componentTextStyle,
461
446
  }}
462
447
  >
463
- <div>
464
- <label
465
- htmlFor="guardianName"
466
- style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
467
- >
468
- Name of Guardian *
469
- </label>
470
- <input
471
- id="guardianName"
472
- type="text"
473
- value={formData.guardianName}
474
- onChange={(e) => onChange("guardianName", e.target.value)}
475
- style={{
476
- ...inputStyle,
477
- borderColor: errors.guardianName
478
- ? "#DC2626"
479
- : inputStyle.border,
480
- }}
481
- placeholder="Enter guardian's full name"
482
- />
483
- {errors.guardianName && (
484
- <div style={errorStyle}>{errors.guardianName}</div>
485
- )}
486
- </div>
487
-
488
- <div>
489
- <label
490
- htmlFor="guardianContact"
491
- style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
492
- >
493
- Contact of Guardian *
494
- </label>
495
- <input
496
- id="guardianContact"
497
- type="text"
498
- value={formData.guardianContact}
499
- onChange={(e) => onChange("guardianContact", e.target.value)}
500
- style={{
501
- ...inputStyle,
502
- borderColor: errors.guardianContact
503
- ? "#DC2626"
504
- : inputStyle.border,
505
- }}
506
- placeholder="Enter guardian's email or phone number"
507
- />
508
- {errors.guardianContact && (
509
- <div style={errorStyle}>{errors.guardianContact}</div>
510
- )}
511
- </div>
512
-
513
- <div>
514
- <label
515
- htmlFor="guardianRelationship"
516
- style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
517
- >
518
- Relationship to Guardian *
519
- </label>
520
- <input
521
- id="guardianRelationship"
522
- type="text"
523
- value={formData.guardianRelationship}
524
- onChange={(e) => onChange("guardianRelationship", e.target.value)}
525
- style={{
526
- ...inputStyle,
527
- borderColor: errors.guardianRelationship
528
- ? "#DC2626"
529
- : inputStyle.border,
530
- }}
531
- placeholder="e.g., Parent, Legal Guardian, etc."
532
- />
533
- {errors.guardianRelationship && (
534
- <div style={errorStyle}>{errors.guardianRelationship}</div>
535
- )}
536
- </div>
537
- </div>
448
+ <p
449
+ style={{
450
+ ...styles.privacyText,
451
+ ...responsiveStyles.privacyText,
452
+ ...componentTextStyle,
453
+ fontSize: "14px",
454
+ lineHeight: "1.4",
455
+ }}
456
+ >
457
+ As you are under the age of consent, we need to collect information
458
+ about your legal guardian to proceed.
459
+ </p>
538
460
 
539
- {errors.general && (
540
461
  <div
541
- role="alert"
542
462
  style={{
543
- color: "#DC2626",
544
- fontSize: "14px",
463
+ display: "flex",
464
+ flexDirection: "column",
465
+ gap: "12px",
545
466
  marginTop: "16px",
546
- padding: "12px",
547
- borderRadius: "6px",
548
- border: "1px solid #FCA5A5",
549
- backgroundColor: "#FEF2F2",
550
- textAlign: "center",
551
467
  }}
552
468
  >
553
- {errors.general}
469
+ <div>
470
+ <label
471
+ htmlFor="guardianName"
472
+ style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
473
+ >
474
+ Name of Guardian *
475
+ </label>
476
+ <input
477
+ id="guardianName"
478
+ type="text"
479
+ value={formData.guardianName}
480
+ onChange={(e) => onChange("guardianName", e.target.value)}
481
+ style={{
482
+ ...inputStyle,
483
+ borderColor: errors.guardianName
484
+ ? "#DC2626"
485
+ : inputStyle.border,
486
+ }}
487
+ placeholder="Enter guardian's full name"
488
+ />
489
+ {errors.guardianName && (
490
+ <div style={errorStyle}>{errors.guardianName}</div>
491
+ )}
492
+ </div>
493
+
494
+ <div>
495
+ <label
496
+ htmlFor="guardianContact"
497
+ style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
498
+ >
499
+ Contact of Guardian *
500
+ </label>
501
+ <input
502
+ id="guardianContact"
503
+ type="text"
504
+ value={formData.guardianContact}
505
+ onChange={(e) => onChange("guardianContact", e.target.value)}
506
+ style={{
507
+ ...inputStyle,
508
+ borderColor: errors.guardianContact
509
+ ? "#DC2626"
510
+ : inputStyle.border,
511
+ }}
512
+ placeholder="Enter guardian's email or phone number"
513
+ />
514
+ {errors.guardianContact && (
515
+ <div style={errorStyle}>{errors.guardianContact}</div>
516
+ )}
517
+ </div>
518
+
519
+ <div>
520
+ <label
521
+ htmlFor="guardianRelationship"
522
+ style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
523
+ >
524
+ Relationship to Guardian *
525
+ </label>
526
+ <input
527
+ id="guardianRelationship"
528
+ type="text"
529
+ value={formData.guardianRelationship}
530
+ onChange={(e) => onChange("guardianRelationship", e.target.value)}
531
+ style={{
532
+ ...inputStyle,
533
+ borderColor: errors.guardianRelationship
534
+ ? "#DC2626"
535
+ : inputStyle.border,
536
+ }}
537
+ placeholder="e.g., Parent, Legal Guardian, etc."
538
+ />
539
+ {errors.guardianRelationship && (
540
+ <div style={errorStyle}>{errors.guardianRelationship}</div>
541
+ )}
542
+ </div>
554
543
  </div>
555
- )}
556
- </div>
557
544
 
558
- <div
559
- style={{
560
- ...styles.bottomSection,
561
- ...responsiveStyles.bottomSection,
562
- ...componentTextStyle,
563
- paddingTop: "12px",
564
- }}
565
- >
566
- <button
567
- style={{
568
- ...styles.button,
569
- ...responsiveStyles.button,
570
- ...styles.acceptButton,
571
- ...acceptButtonStyle,
572
- }}
573
- onClick={onNext}
574
- disabled={isSubmitting}
575
- type="button"
576
- aria-label={
577
- isSubmitting
578
- ? "Submitting guardian information"
579
- : "Submit guardian information and proceed"
580
- }
581
- >
582
- {isSubmitting ? "Submitting..." : "Next"}
583
- </button>
584
- <button
545
+ {errors.general && (
546
+ <div
547
+ role="alert"
548
+ style={{
549
+ color: "#DC2626",
550
+ fontSize: "14px",
551
+ marginTop: "16px",
552
+ padding: "12px",
553
+ borderRadius: "6px",
554
+ border: "1px solid #FCA5A5",
555
+ backgroundColor: "#FEF2F2",
556
+ textAlign: "center",
557
+ }}
558
+ >
559
+ {errors.general}
560
+ </div>
561
+ )}
562
+ </div>
563
+
564
+ <div
585
565
  style={{
586
- ...styles.button,
587
- ...responsiveStyles.button,
588
- ...styles.cancelButton,
589
- ...declineButtonStyle,
566
+ ...styles.bottomSection,
567
+ ...responsiveStyles.bottomSection,
568
+ ...componentTextStyle,
569
+ paddingTop: "12px",
590
570
  }}
591
- onClick={onDecline}
592
- type="button"
593
- aria-label="Cancel guardian information form"
594
571
  >
595
- Cancel
596
- </button>
597
- </div>
598
- </>
599
- );
600
- };
572
+ <button
573
+ style={{
574
+ ...styles.button,
575
+ ...responsiveStyles.button,
576
+ ...styles.acceptButton,
577
+ ...acceptButtonStyle,
578
+ }}
579
+ onClick={onNext}
580
+ disabled={isSubmitting}
581
+ type="button"
582
+ aria-label={
583
+ isSubmitting
584
+ ? "Submitting guardian information"
585
+ : "Submit guardian information and proceed"
586
+ }
587
+ >
588
+ {isSubmitting ? "Submitting..." : "Next"}
589
+ </button>
590
+ <button
591
+ style={{
592
+ ...styles.button,
593
+ ...responsiveStyles.button,
594
+ ...styles.cancelButton,
595
+ ...declineButtonStyle,
596
+ }}
597
+ onClick={onDecline}
598
+ type="button"
599
+ aria-label="Cancel guardian information form"
600
+ >
601
+ Cancel
602
+ </button>
603
+ </div>
604
+ </>
605
+ );
606
+ };
601
607
 
602
608
  /**
603
609
  * AgeVerification Component
@@ -629,122 +635,122 @@ const AgeVerification: React.FC<{
629
635
  logoUrl,
630
636
  isMobile,
631
637
  }) => {
632
- return (
633
- <>
634
- <div style={{ ...styles.topSection, ...componentTextStyle }}>
635
- <div style={styles.topLeft}>
636
- <img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
637
- <h2
638
- id="age-verification-title"
638
+ return (
639
+ <>
640
+ <div style={{ ...styles.topSection, ...componentTextStyle }}>
641
+ <div style={styles.topLeft}>
642
+ <img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
643
+ <h2
644
+ id="age-verification-title"
645
+ style={{
646
+ ...styles.title,
647
+ ...responsiveStyles.title,
648
+ ...componentHeadingStyle,
649
+ }}
650
+ >
651
+ Age Verification Required
652
+ </h2>
653
+ </div>
654
+ <button
655
+ onClick={onClose}
639
656
  style={{
640
- ...styles.title,
641
- ...responsiveStyles.title,
642
- ...componentHeadingStyle,
657
+ background: "none",
658
+ border: "none",
659
+ color: componentHeadingStyle?.color || "#323B4B",
660
+ cursor: "pointer",
661
+ padding: isMobile ? "2px" : "4px",
662
+ display: "flex",
663
+ alignItems: "center",
664
+ justifyContent: "center",
643
665
  }}
666
+ aria-label="Close modal"
644
667
  >
645
- Age Verification Required
646
- </h2>
668
+ <svg
669
+ width={isMobile ? "18" : "20"}
670
+ height={isMobile ? "18" : "20"}
671
+ viewBox="0 0 20 20"
672
+ fill="none"
673
+ xmlns="http://www.w3.org/2000/svg"
674
+ aria-hidden="true"
675
+ >
676
+ <path
677
+ d="M15 5L5 15M5 5L15 15"
678
+ stroke="currentColor"
679
+ strokeWidth="2"
680
+ strokeLinecap="round"
681
+ strokeLinejoin="round"
682
+ />
683
+ </svg>
684
+ </button>
647
685
  </div>
648
- <button
649
- onClick={onClose}
650
- style={{
651
- background: "none",
652
- border: "none",
653
- color: componentHeadingStyle?.color || "#323B4B",
654
- cursor: "pointer",
655
- padding: isMobile ? "2px" : "4px",
656
- display: "flex",
657
- alignItems: "center",
658
- justifyContent: "center",
659
- }}
660
- aria-label="Close modal"
661
- >
662
- <svg
663
- width={isMobile ? "18" : "20"}
664
- height={isMobile ? "18" : "20"}
665
- viewBox="0 0 20 20"
666
- fill="none"
667
- xmlns="http://www.w3.org/2000/svg"
668
- aria-hidden="true"
669
- >
670
- <path
671
- d="M15 5L5 15M5 5L15 15"
672
- stroke="currentColor"
673
- strokeWidth="2"
674
- strokeLinecap="round"
675
- strokeLinejoin="round"
676
- />
677
- </svg>
678
- </button>
679
- </div>
680
686
 
681
- <div
682
- style={{
683
- ...styles.middleSection,
684
- ...responsiveStyles.middleSection,
685
- ...componentTextStyle,
686
- }}
687
- >
688
- <p
687
+ <div
689
688
  style={{
690
- ...styles.privacyText,
691
- ...responsiveStyles.privacyText,
689
+ ...styles.middleSection,
690
+ ...responsiveStyles.middleSection,
692
691
  ...componentTextStyle,
693
- marginBottom: "16px",
694
692
  }}
695
693
  >
696
- To proceed with this consent form, we need to verify your age.
697
- </p>
694
+ <p
695
+ style={{
696
+ ...styles.privacyText,
697
+ ...responsiveStyles.privacyText,
698
+ ...componentTextStyle,
699
+ marginBottom: "16px",
700
+ }}
701
+ >
702
+ To proceed with this consent form, we need to verify your age.
703
+ </p>
698
704
 
699
- <p
700
- style={{
701
- ...styles.subTitle,
702
- ...componentTextStyle,
703
- marginBottom: "24px",
704
- textAlign: "left",
705
- }}
706
- >
707
- Are you 18 years of age or older?
708
- </p>
709
- </div>
705
+ <p
706
+ style={{
707
+ ...styles.subTitle,
708
+ ...componentTextStyle,
709
+ marginBottom: "24px",
710
+ textAlign: "left",
711
+ }}
712
+ >
713
+ Are you 18 years of age or older?
714
+ </p>
715
+ </div>
710
716
 
711
- <div
712
- style={{
713
- ...styles.bottomSection,
714
- ...responsiveStyles.bottomSection,
715
- ...componentTextStyle,
716
- }}
717
- >
718
- <button
719
- style={{
720
- ...styles.button,
721
- ...responsiveStyles.button,
722
- ...styles.acceptButton,
723
- ...acceptButtonStyle,
724
- }}
725
- onClick={onYes}
726
- type="button"
727
- aria-label="Confirm that I am 18 years of age or older"
728
- >
729
- Yes, I am 18 or older
730
- </button>
731
- <button
717
+ <div
732
718
  style={{
733
- ...styles.button,
734
- ...responsiveStyles.button,
735
- ...styles.cancelButton,
736
- ...declineButtonStyle,
719
+ ...styles.bottomSection,
720
+ ...responsiveStyles.bottomSection,
721
+ ...componentTextStyle,
737
722
  }}
738
- onClick={onNo}
739
- type="button"
740
- aria-label="Indicate that I am under 18 years of age"
741
723
  >
742
- No, I am under 18
743
- </button>
744
- </div>
745
- </>
746
- );
747
- };
724
+ <button
725
+ style={{
726
+ ...styles.button,
727
+ ...responsiveStyles.button,
728
+ ...styles.acceptButton,
729
+ ...acceptButtonStyle,
730
+ }}
731
+ onClick={onYes}
732
+ type="button"
733
+ aria-label="Confirm that I am 18 years of age or older"
734
+ >
735
+ Yes, I am 18 or older
736
+ </button>
737
+ <button
738
+ style={{
739
+ ...styles.button,
740
+ ...responsiveStyles.button,
741
+ ...styles.cancelButton,
742
+ ...declineButtonStyle,
743
+ }}
744
+ onClick={onNo}
745
+ type="button"
746
+ aria-label="Indicate that I am under 18 years of age"
747
+ >
748
+ No, I am under 18
749
+ </button>
750
+ </div>
751
+ </>
752
+ );
753
+ };
748
754
 
749
755
  // =============================================================================
750
756
  // MAIN COMPONENT
@@ -770,6 +776,8 @@ export const RedactoNoticeConsent = ({
770
776
  settings,
771
777
  applicationId,
772
778
  validateAgainst = "all",
779
+ includeFullyConsentedData = false,
780
+ reviewModeButtonText = "Continue",
773
781
  }: Props) => {
774
782
  // =============================================================================
775
783
  // PROP VALIDATION
@@ -838,6 +846,8 @@ export const RedactoNoticeConsent = ({
838
846
  Record<string, boolean>
839
847
  >({});
840
848
  const [hasAlreadyConsented, setHasAlreadyConsented] = useState(false);
849
+ const [isFullyConsentedReviewMode, setIsFullyConsentedReviewMode] =
850
+ useState(false);
841
851
 
842
852
  // UI states
843
853
  const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
@@ -1058,6 +1068,12 @@ export const RedactoNoticeConsent = ({
1058
1068
  }
1059
1069
  }, [hasAlreadyConsented, onAccept]);
1060
1070
 
1071
+ const isDefaultLanguage = (lang: string): boolean => {
1072
+ if (!content) return true;
1073
+ if (lang === "English" || lang === "en" || lang === "EN") return true;
1074
+ return false;
1075
+ };
1076
+
1061
1077
  const getTranslatedText = (
1062
1078
  key: string,
1063
1079
  defaultText: string,
@@ -1065,8 +1081,8 @@ export const RedactoNoticeConsent = ({
1065
1081
  ): string => {
1066
1082
  if (!content) return defaultText;
1067
1083
 
1068
- // If English is selected, use the base content from active_config
1069
- if (selectedLanguage === "English" || selectedLanguage === "en") {
1084
+ // If default language is selected, use the base content from active_config
1085
+ if (isDefaultLanguage(selectedLanguage)) {
1070
1086
  // Check for specific keys in content object
1071
1087
  if (
1072
1088
  key === "privacy_policy_anchor_text" &&
@@ -1110,7 +1126,16 @@ export const RedactoNoticeConsent = ({
1110
1126
 
1111
1127
  if (itemId) {
1112
1128
  if (key === "purposes.name" || key === "purposes.description") {
1113
- return translationMap.purposes?.[itemId] || defaultText;
1129
+ const purposeData = translationMap.purposes?.[itemId];
1130
+ if (purposeData) {
1131
+ if (typeof purposeData === "string") {
1132
+ return key === "purposes.name" ? purposeData : defaultText;
1133
+ }
1134
+ return key === "purposes.name"
1135
+ ? purposeData.name || defaultText
1136
+ : purposeData.description || defaultText;
1137
+ }
1138
+ return defaultText;
1114
1139
  }
1115
1140
  if (key.startsWith("data_elements.")) {
1116
1141
  return translationMap.data_elements?.[itemId] || defaultText;
@@ -1118,13 +1143,37 @@ export const RedactoNoticeConsent = ({
1118
1143
  }
1119
1144
 
1120
1145
  const value = translationMap[key];
1121
- if (typeof value === "string") {
1146
+ if (typeof value === "string" && value) {
1122
1147
  return value;
1123
1148
  }
1124
1149
 
1125
1150
  return defaultText;
1126
1151
  };
1127
1152
 
1153
+ const getTranslatedDpoText = (
1154
+ key: string,
1155
+ defaultText: string
1156
+ ): string => {
1157
+ if (!content?.dpo_info) return defaultText;
1158
+
1159
+ if (isDefaultLanguage(selectedLanguage)) {
1160
+ return (content.dpo_info as Record<string, string>)[key] || defaultText;
1161
+ }
1162
+
1163
+ if (!content.supported_languages_and_translations) return defaultText;
1164
+
1165
+ const translationMap = content.supported_languages_and_translations[
1166
+ selectedLanguage
1167
+ ] as TranslationObject | undefined;
1168
+
1169
+ if (translationMap?.dpo_info) {
1170
+ const translated = (translationMap.dpo_info as Record<string, string | undefined>)[key];
1171
+ if (translated) return translated;
1172
+ }
1173
+
1174
+ return (content.dpo_info as Record<string, string>)[key] || defaultText;
1175
+ };
1176
+
1128
1177
  // =============================================================================
1129
1178
  // EVENT HANDLERS
1130
1179
  // =============================================================================
@@ -1161,25 +1210,25 @@ export const RedactoNoticeConsent = ({
1161
1210
  language,
1162
1211
  specific_uuid: applicationId,
1163
1212
  validate_against: validateAgainst,
1213
+ include_fully_consented_data: includeFullyConsentedData,
1164
1214
  signal: abortControllerRef.current?.signal,
1165
1215
  });
1166
- setContent(consentContentData.detail.active_config);
1167
- if (consentContentData.detail.active_config.default_language) {
1216
+ const activeConfig = consentContentData.detail.active_config;
1217
+ setContent(activeConfig);
1218
+ if (activeConfig.default_language) {
1168
1219
  // Normalize "en" to "English" for consistency
1169
- const defaultLang =
1170
- consentContentData.detail.active_config.default_language;
1220
+ const defaultLang = activeConfig.default_language;
1171
1221
  setSelectedLanguage(
1172
1222
  defaultLang === "en" || defaultLang === "EN" ? "English" : defaultLang
1173
1223
  );
1174
1224
  }
1175
- const initialCollapsedState =
1176
- consentContentData.detail.active_config.purposes.reduce(
1177
- (acc, purpose) => ({
1178
- ...acc,
1179
- [purpose.uuid]: true,
1180
- }),
1181
- {}
1182
- );
1225
+ const initialCollapsedState = activeConfig.purposes.reduce(
1226
+ (acc, purpose) => ({
1227
+ ...acc,
1228
+ [purpose.uuid]: true,
1229
+ }),
1230
+ {}
1231
+ );
1183
1232
  setCollapsedPurposes(initialCollapsedState);
1184
1233
 
1185
1234
  // Initialize selected states - handle reconsent pre-population
@@ -1189,39 +1238,37 @@ export const RedactoNoticeConsent = ({
1189
1238
  // Categorize purposes for reconsent UI
1190
1239
  const newCategorizedPurposes = isReconsentRequired
1191
1240
  ? {
1192
- alreadyConsented:
1193
- consentContentData.detail.active_config.purposes.filter(
1194
- (purpose) => {
1195
- const selection = purposeSelections?.[purpose.uuid];
1196
- return (
1197
- selection &&
1198
- (selection.status === "ACTIVE" ||
1199
- selection.status === "EXPIRED") &&
1200
- !selection.needs_reconsent
1201
- );
1202
- }
1203
- ),
1204
- needsConsent:
1205
- consentContentData.detail.active_config.purposes.filter(
1206
- (purpose) => {
1207
- const selection = purposeSelections?.[purpose.uuid];
1208
- return (
1209
- !selection ||
1210
- selection.status !== "ACTIVE" ||
1211
- selection.needs_reconsent
1212
- );
1213
- }
1214
- ),
1215
- }
1241
+ alreadyConsented: activeConfig.purposes.filter(
1242
+ (purpose) => {
1243
+ const selection = purposeSelections?.[purpose.uuid];
1244
+ return (
1245
+ selection &&
1246
+ (selection.status === "ACTIVE" ||
1247
+ selection.status === "EXPIRED") &&
1248
+ !selection.needs_reconsent
1249
+ );
1250
+ }
1251
+ ),
1252
+ needsConsent: activeConfig.purposes.filter(
1253
+ (purpose) => {
1254
+ const selection = purposeSelections?.[purpose.uuid];
1255
+ return (
1256
+ !selection ||
1257
+ selection.status !== "ACTIVE" ||
1258
+ selection.needs_reconsent
1259
+ );
1260
+ }
1261
+ ),
1262
+ }
1216
1263
  : null;
1217
1264
 
1218
1265
  setCategorizedPurposes(newCategorizedPurposes);
1219
1266
 
1220
1267
  const initialPurposeState: Record<string, boolean> =
1221
- consentContentData.detail.active_config.purposes.reduce(
1268
+ activeConfig.purposes.reduce(
1222
1269
  (acc: Record<string, boolean>, purpose) => {
1223
- if (isReconsentRequired && purposeSelections?.[purpose.uuid]) {
1224
- // Pre-populate with existing selection for reconsent
1270
+ if (purposeSelections?.[purpose.uuid]) {
1271
+ // Pre-populate with existing selection (reconsent or magic link flow)
1225
1272
  acc[purpose.uuid] = purposeSelections[purpose.uuid].selected;
1226
1273
  } else {
1227
1274
  // Default to false for new consent
@@ -1234,15 +1281,14 @@ export const RedactoNoticeConsent = ({
1234
1281
  setSelectedPurposes(initialPurposeState);
1235
1282
 
1236
1283
  const initialDataElementState: Record<string, boolean> =
1237
- consentContentData.detail.active_config.purposes.reduce(
1284
+ activeConfig.purposes.reduce(
1238
1285
  (acc: Record<string, boolean>, purpose) => {
1239
1286
  purpose.data_elements.forEach((element) => {
1240
1287
  const combinedId = `${purpose.uuid}-${element.uuid}`;
1241
1288
  if (
1242
- isReconsentRequired &&
1243
1289
  purposeSelections?.[purpose.uuid]?.data_elements?.[element.uuid]
1244
1290
  ) {
1245
- // Pre-populate with existing selection for reconsent
1291
+ // Pre-populate with existing selection (reconsent or magic link flow)
1246
1292
  acc[combinedId] =
1247
1293
  purposeSelections[purpose.uuid].data_elements[
1248
1294
  element.uuid
@@ -1259,6 +1305,7 @@ export const RedactoNoticeConsent = ({
1259
1305
  setSelectedDataElements(initialDataElementState);
1260
1306
  // Store initial selections for already-consented purposes to track modifications
1261
1307
  if (isReconsentRequired && newCategorizedPurposes) {
1308
+ setIsFullyConsentedReviewMode(false);
1262
1309
  const initialSelections: Record<string, boolean> = {};
1263
1310
  newCategorizedPurposes.alreadyConsented.forEach((purpose) => {
1264
1311
  purpose.data_elements.forEach((element) => {
@@ -1275,7 +1322,39 @@ export const RedactoNoticeConsent = ({
1275
1322
  });
1276
1323
  });
1277
1324
  setInitialDataElementSelections(initialSelections);
1325
+ } else if (includeFullyConsentedData && purposeSelections) {
1326
+ // Fully consented review mode: all purposes show green ticks, need initialDataElementSelections
1327
+ const allPurposesSelected =
1328
+ activeConfig.purposes.length > 0 &&
1329
+ activeConfig.purposes.every(
1330
+ (p) => purposeSelections[p.uuid]?.selected === true
1331
+ );
1332
+ setIsFullyConsentedReviewMode(allPurposesSelected);
1333
+ if (allPurposesSelected) {
1334
+ const initialSelections: Record<string, boolean> = {};
1335
+ activeConfig.purposes.forEach(
1336
+ (purpose) => {
1337
+ purpose.data_elements.forEach((element) => {
1338
+ const combinedId = `${purpose.uuid}-${element.uuid}`;
1339
+ if (
1340
+ purposeSelections[purpose.uuid]?.data_elements?.[element.uuid]
1341
+ ) {
1342
+ initialSelections[combinedId] =
1343
+ purposeSelections[purpose.uuid].data_elements[element.uuid]
1344
+ .selected;
1345
+ } else {
1346
+ initialSelections[combinedId] = false;
1347
+ }
1348
+ });
1349
+ }
1350
+ );
1351
+ setInitialDataElementSelections(initialSelections);
1352
+ } else {
1353
+ setIsFullyConsentedReviewMode(false);
1354
+ setInitialDataElementSelections({});
1355
+ }
1278
1356
  } else {
1357
+ setIsFullyConsentedReviewMode(false);
1279
1358
  setInitialDataElementSelections({});
1280
1359
  }
1281
1360
 
@@ -1293,7 +1372,6 @@ export const RedactoNoticeConsent = ({
1293
1372
  return; // Don't update state for cancelled requests
1294
1373
  }
1295
1374
 
1296
- console.error(err);
1297
1375
  const error = err as Error & { status?: number };
1298
1376
  setFetchError(error);
1299
1377
  if (error.status === 409) {
@@ -1319,6 +1397,7 @@ export const RedactoNoticeConsent = ({
1319
1397
  language,
1320
1398
  applicationId,
1321
1399
  validateAgainst,
1400
+ includeFullyConsentedData,
1322
1401
  // Removed onError and isRefreshingToken from dependencies to prevent duplicate API calls
1323
1402
  // onError is only called on errors, not needed for initial data fetching
1324
1403
  ]);
@@ -1457,7 +1536,7 @@ export const RedactoNoticeConsent = ({
1457
1536
  selected: element.required
1458
1537
  ? true
1459
1538
  : selectedDataElements[`${purpose.uuid}-${element.uuid}`] ||
1460
- false,
1539
+ false,
1461
1540
  })),
1462
1541
  };
1463
1542
  }),
@@ -1475,13 +1554,12 @@ export const RedactoNoticeConsent = ({
1475
1554
  return;
1476
1555
  }
1477
1556
 
1478
- console.error("Error submitting consent:", error);
1479
1557
  const err = error as Error & { status?: number };
1480
- if (err.status === 500) {
1481
- setErrorMessage(
1482
- "An error occurred while submitting your consent. Please try again later."
1483
- );
1484
- }
1558
+ const message =
1559
+ err.status === 500
1560
+ ? "An error occurred while submitting your consent. Please try again later."
1561
+ : err.message || "Failed to submit consent. Please try again.";
1562
+ setErrorMessage(message);
1485
1563
  onError?.(error as Error);
1486
1564
  } finally {
1487
1565
  setIsSubmittingAccept(false);
@@ -1716,9 +1794,9 @@ export const RedactoNoticeConsent = ({
1716
1794
  // Determine which purposes to include based on UI mode
1717
1795
  const purposesToProcess = categorizedPurposes
1718
1796
  ? [
1719
- ...categorizedPurposes.alreadyConsented,
1720
- ...categorizedPurposes.needsConsent,
1721
- ]
1797
+ ...categorizedPurposes.alreadyConsented,
1798
+ ...categorizedPurposes.needsConsent,
1799
+ ]
1722
1800
  : content.purposes || [];
1723
1801
 
1724
1802
  purposesToProcess.forEach((purpose) => {
@@ -1748,12 +1826,32 @@ export const RedactoNoticeConsent = ({
1748
1826
  }
1749
1827
  });
1750
1828
 
1751
- // 7. Confirm button text audio
1829
+ // 7. Additional text audio
1830
+ if (content.additional_text && ttsAudioUrls.additional_text_audio_url) {
1831
+ sequence.push(ttsAudioUrls.additional_text_audio_url);
1832
+ }
1833
+
1834
+ // 8. Privacy center anchor text audio
1835
+ if (content.privacy_center_url && ttsAudioUrls.privacy_center_anchor_text_audio_url) {
1836
+ sequence.push(ttsAudioUrls.privacy_center_anchor_text_audio_url);
1837
+ }
1838
+
1839
+ // 9. DPO info audio
1840
+ if (content.dpo_info && ttsAudioUrls.dpo_info_audio) {
1841
+ const dpoAudio = ttsAudioUrls.dpo_info_audio;
1842
+ DPO_AUDIO_ELEMENT_MAP.forEach(({ audioKey }) => {
1843
+ if (dpoAudio[audioKey]) {
1844
+ sequence.push(dpoAudio[audioKey]);
1845
+ }
1846
+ });
1847
+ }
1848
+
1849
+ // 10. Confirm button text audio
1752
1850
  if (ttsAudioUrls.confirm_button_text_audio_url) {
1753
1851
  sequence.push(ttsAudioUrls.confirm_button_text_audio_url);
1754
1852
  }
1755
1853
 
1756
- // 8. Decline button text audio
1854
+ // 11. Decline button text audio
1757
1855
  if (ttsAudioUrls.decline_button_text_audio_url) {
1758
1856
  sequence.push(ttsAudioUrls.decline_button_text_audio_url);
1759
1857
  }
@@ -1795,9 +1893,9 @@ export const RedactoNoticeConsent = ({
1795
1893
  // 6. Purposes and data elements
1796
1894
  const purposesToProcess = categorizedPurposes
1797
1895
  ? [
1798
- ...categorizedPurposes.alreadyConsented,
1799
- ...categorizedPurposes.needsConsent,
1800
- ]
1896
+ ...categorizedPurposes.alreadyConsented,
1897
+ ...categorizedPurposes.needsConsent,
1898
+ ]
1801
1899
  : content.purposes || [];
1802
1900
 
1803
1901
  purposesToProcess.forEach((purpose) => {
@@ -1823,12 +1921,32 @@ export const RedactoNoticeConsent = ({
1823
1921
  }
1824
1922
  });
1825
1923
 
1826
- // 7. Confirm button text audio
1924
+ // 7. Additional text audio
1925
+ if (content.additional_text && ttsAudioUrls.additional_text_audio_url) {
1926
+ elementIds.push("additional-text");
1927
+ }
1928
+
1929
+ // 8. Privacy center anchor text audio
1930
+ if (content.privacy_center_url && ttsAudioUrls.privacy_center_anchor_text_audio_url) {
1931
+ elementIds.push("privacy-center-link");
1932
+ }
1933
+
1934
+ // 9. DPO info audio
1935
+ if (content.dpo_info && ttsAudioUrls.dpo_info_audio) {
1936
+ const dpoAudio = ttsAudioUrls.dpo_info_audio;
1937
+ DPO_AUDIO_ELEMENT_MAP.forEach(({ audioKey, elementId }) => {
1938
+ if (dpoAudio[audioKey]) {
1939
+ elementIds.push(elementId);
1940
+ }
1941
+ });
1942
+ }
1943
+
1944
+ // 10. Confirm button text audio
1827
1945
  if (ttsAudioUrls.confirm_button_text_audio_url) {
1828
1946
  elementIds.push("confirm-button-text");
1829
1947
  }
1830
1948
 
1831
- // 8. Decline button text audio
1949
+ // 11. Decline button text audio
1832
1950
  if (ttsAudioUrls.decline_button_text_audio_url) {
1833
1951
  elementIds.push("decline-button-text");
1834
1952
  }
@@ -1950,9 +2068,9 @@ export const RedactoNoticeConsent = ({
1950
2068
  // Find which purpose we're on (rough estimate)
1951
2069
  const purposesToProcess = categorizedPurposes
1952
2070
  ? [
1953
- ...categorizedPurposes.alreadyConsented,
1954
- ...categorizedPurposes.needsConsent,
1955
- ]
2071
+ ...categorizedPurposes.alreadyConsented,
2072
+ ...categorizedPurposes.needsConsent,
2073
+ ]
1956
2074
  : content.purposes || [];
1957
2075
 
1958
2076
  let audioOffset = headerAudioCount;
@@ -2021,9 +2139,9 @@ export const RedactoNoticeConsent = ({
2021
2139
  rect.top >= 0 &&
2022
2140
  rect.left >= 0 &&
2023
2141
  rect.bottom <=
2024
- (window.innerHeight || document.documentElement.clientHeight) &&
2142
+ (window.innerHeight || document.documentElement.clientHeight) &&
2025
2143
  rect.right <=
2026
- (window.innerWidth || document.documentElement.clientWidth);
2144
+ (window.innerWidth || document.documentElement.clientWidth);
2027
2145
 
2028
2146
  if (!isVisible) {
2029
2147
  element.scrollIntoView({
@@ -2080,9 +2198,7 @@ export const RedactoNoticeConsent = ({
2080
2198
  })
2081
2199
  .catch((error) => {
2082
2200
  // Handle errors gracefully - don't break the UI if TTS API fails
2083
- // Only log if it's not an AbortError (which is expected when component unmounts or request is cancelled)
2084
2201
  if (error instanceof Error && error.name !== "AbortError") {
2085
- console.warn("Failed to fetch TTS audio URLs:", error.message);
2086
2202
  setIsTTSAvailable(false);
2087
2203
  setTtsAudioUrls(null);
2088
2204
  }
@@ -2665,7 +2781,7 @@ export const RedactoNoticeConsent = ({
2665
2781
  {getTranslatedText(
2666
2782
  "privacy_policy_anchor_text",
2667
2783
  content?.privacy_policy_anchor_text ||
2668
- "Privacy Policy"
2784
+ "Privacy Policy"
2669
2785
  )}
2670
2786
  </a>
2671
2787
  </span>
@@ -2746,12 +2862,142 @@ export const RedactoNoticeConsent = ({
2746
2862
  onPurposeCollapse={togglePurposeCollapse}
2747
2863
  onDataElementToggle={handleDataElementCheckboxChange}
2748
2864
  getTranslatedText={getTranslatedText}
2749
- isAlreadyConsented={false}
2865
+ isAlreadyConsented={isFullyConsentedReviewMode}
2750
2866
  selectedLanguage={selectedLanguage}
2867
+ initialDataElementSelections={initialDataElementSelections}
2751
2868
  />
2752
2869
  ))}
2753
2870
  </div>
2754
2871
  )}
2872
+
2873
+ {(content?.privacy_center_url || content?.dpo_info) && (
2874
+ <div
2875
+ style={{
2876
+ ...styles.privacyText,
2877
+ ...responsiveStyles.privacyText,
2878
+ ...componentTextStyle,
2879
+ fontSize: isMobile ? "13px" : "14px",
2880
+ lineHeight: "1.4",
2881
+ borderTop: "1px solid rgba(152, 162, 179, 0.3)",
2882
+ paddingTop: "12px",
2883
+ marginTop: "12px",
2884
+ }}
2885
+ >
2886
+ <p style={{ margin: 0, opacity: 0.6 }}>
2887
+ {content.privacy_center_url && (
2888
+ <>
2889
+ {content.additional_text && (
2890
+ <span id="additional-text">
2891
+ {getTranslatedText(
2892
+ "additional_text",
2893
+ content.additional_text
2894
+ )}
2895
+ </span>
2896
+ )}{" "}
2897
+ <a
2898
+ id="privacy-center-link"
2899
+ style={{ ...styles.link, ...linkStyle }}
2900
+ href={content.privacy_center_url}
2901
+ target="_blank"
2902
+ rel="noopener noreferrer"
2903
+ >
2904
+ {getTranslatedText(
2905
+ "privacy_center_anchor_text",
2906
+ content.privacy_center_anchor_text || "Privacy Center"
2907
+ )}
2908
+ </a>
2909
+ .
2910
+ </>
2911
+ )}
2912
+ {content.dpo_info && (
2913
+ <>
2914
+ {content.privacy_center_url && <br />}
2915
+ <span id="dpo-grievance-text">
2916
+ {getTranslatedDpoText(
2917
+ "grievance_text",
2918
+ content.dpo_info.grievance_text || ""
2919
+ )}
2920
+ </span>{" "}
2921
+ {content.dpo_info.grievance_url && (
2922
+ <a
2923
+ id="dpo-grievance-link"
2924
+ style={{ ...styles.link, ...linkStyle }}
2925
+ href={content.dpo_info.grievance_url}
2926
+ target="_blank"
2927
+ rel="noopener noreferrer"
2928
+ >
2929
+ {getTranslatedDpoText(
2930
+ "grievance_anchor_text",
2931
+ content.dpo_info.grievance_anchor_text || "click here"
2932
+ )}
2933
+ </a>
2934
+ )}
2935
+ {content.dpo_info.grievance_email && (
2936
+ <>
2937
+ {" "}
2938
+ <span id="dpo-grievance-email-connector">
2939
+ {getTranslatedDpoText(
2940
+ "grievance_email_connector_text",
2941
+ content.dpo_info.grievance_email_connector_text || "or email to"
2942
+ )}
2943
+ </span>
2944
+ {" "}
2945
+ <a
2946
+ id="dpo-grievance-email"
2947
+ style={{ ...styles.link, ...linkStyle }}
2948
+ href={`mailto:${content.dpo_info.grievance_email}`}
2949
+ >
2950
+ {content.dpo_info.grievance_email}
2951
+ </a>
2952
+ </>
2953
+ )}
2954
+ <br />
2955
+ <span id="dpo-dp-board-text">
2956
+ {getTranslatedDpoText(
2957
+ "dp_board_text",
2958
+ content.dpo_info.dp_board_text || ""
2959
+ )}
2960
+ </span>{" "}
2961
+ {content.dpo_info.dp_board_url && (
2962
+ <a
2963
+ id="dpo-dp-board-link"
2964
+ style={{ ...styles.link, ...linkStyle }}
2965
+ href={content.dpo_info.dp_board_url}
2966
+ target="_blank"
2967
+ rel="noopener noreferrer"
2968
+ >
2969
+ {getTranslatedDpoText(
2970
+ "dp_board_anchor_text",
2971
+ content.dpo_info.dp_board_anchor_text || "here"
2972
+ )}
2973
+ </a>
2974
+ )}
2975
+ <br />
2976
+ <span id="dpo-dpo-text">
2977
+ {getTranslatedDpoText(
2978
+ "dpo_text",
2979
+ content.dpo_info.dpo_text || ""
2980
+ )}
2981
+ </span>{" "}
2982
+ {content.dpo_info.dpo_url && (
2983
+ <a
2984
+ id="dpo-dpo-link"
2985
+ style={{ ...styles.link, ...linkStyle }}
2986
+ href={content.dpo_info.dpo_url}
2987
+ target="_blank"
2988
+ rel="noopener noreferrer"
2989
+ >
2990
+ {getTranslatedDpoText(
2991
+ "dpo_anchor_text",
2992
+ content.dpo_info.dpo_anchor_text || "Click here"
2993
+ )}
2994
+ </a>
2995
+ )}
2996
+ </>
2997
+ )}
2998
+ </p>
2999
+ </div>
3000
+ )}
2755
3001
  </div>
2756
3002
  <div
2757
3003
  style={{
@@ -2760,53 +3006,72 @@ export const RedactoNoticeConsent = ({
2760
3006
  ...sectionStyle,
2761
3007
  }}
2762
3008
  >
2763
- <button
2764
- ref={lastFocusableRef}
2765
- id="confirm-button-text"
2766
- style={{
2767
- ...styles.button,
2768
- ...responsiveStyles.button,
2769
- ...styles.acceptButton,
2770
- ...acceptButtonStyle,
2771
- opacity: acceptDisabled ? 0.5 : 1,
2772
- }}
2773
- onClick={handleAccept}
2774
- disabled={acceptDisabled || isSubmittingDecline}
2775
- aria-label={getTranslatedText(
2776
- "confirm_button_text",
2777
- content?.confirm_button_text || "Accept"
2778
- )}
2779
- >
2780
- {isSubmittingAccept
2781
- ? "Processing..."
2782
- : getTranslatedText(
3009
+ {isFullyConsentedReviewMode ? (
3010
+ <button
3011
+ ref={lastFocusableRef}
3012
+ id="review-mode-continue-button"
3013
+ style={{
3014
+ ...styles.button,
3015
+ ...responsiveStyles.button,
3016
+ ...styles.acceptButton,
3017
+ ...acceptButtonStyle,
3018
+ }}
3019
+ onClick={() => onAccept?.()}
3020
+ aria-label={reviewModeButtonText}
3021
+ >
3022
+ {reviewModeButtonText}
3023
+ </button>
3024
+ ) : (
3025
+ <>
3026
+ <button
3027
+ ref={lastFocusableRef}
3028
+ id="confirm-button-text"
3029
+ style={{
3030
+ ...styles.button,
3031
+ ...responsiveStyles.button,
3032
+ ...styles.acceptButton,
3033
+ ...acceptButtonStyle,
3034
+ opacity: acceptDisabled ? 0.5 : 1,
3035
+ }}
3036
+ onClick={handleAccept}
3037
+ disabled={acceptDisabled || isSubmittingDecline}
3038
+ aria-label={getTranslatedText(
2783
3039
  "confirm_button_text",
2784
3040
  content?.confirm_button_text || "Accept"
2785
3041
  )}
2786
- </button>
2787
- <button
2788
- id="decline-button-text"
2789
- style={{
2790
- ...styles.button,
2791
- ...responsiveStyles.button,
2792
- ...styles.cancelButton,
2793
- ...declineButtonStyle,
2794
- opacity: isSubmittingAccept ? 0.5 : 1,
2795
- }}
2796
- onClick={handleDecline}
2797
- disabled={isSubmittingDecline || isSubmittingAccept}
2798
- aria-label={getTranslatedText(
2799
- "decline_button_text",
2800
- content?.decline_button_text || "Decline"
2801
- )}
2802
- >
2803
- {isSubmittingDecline
2804
- ? "Processing..."
2805
- : getTranslatedText(
3042
+ >
3043
+ {isSubmittingAccept
3044
+ ? "Processing..."
3045
+ : getTranslatedText(
3046
+ "confirm_button_text",
3047
+ content?.confirm_button_text || "Accept"
3048
+ )}
3049
+ </button>
3050
+ <button
3051
+ id="decline-button-text"
3052
+ style={{
3053
+ ...styles.button,
3054
+ ...responsiveStyles.button,
3055
+ ...styles.cancelButton,
3056
+ ...declineButtonStyle,
3057
+ opacity: isSubmittingAccept ? 0.5 : 1,
3058
+ }}
3059
+ onClick={handleDecline}
3060
+ disabled={isSubmittingDecline || isSubmittingAccept}
3061
+ aria-label={getTranslatedText(
2806
3062
  "decline_button_text",
2807
3063
  content?.decline_button_text || "Decline"
2808
3064
  )}
2809
- </button>
3065
+ >
3066
+ {isSubmittingDecline
3067
+ ? "Processing..."
3068
+ : getTranslatedText(
3069
+ "decline_button_text",
3070
+ content?.decline_button_text || "Decline"
3071
+ )}
3072
+ </button>
3073
+ </>
3074
+ )}
2810
3075
  </div>
2811
3076
  </div>
2812
3077
  )}