@redacto.io/consent-sdk-react 3.3.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,6 +1210,7 @@ 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
1216
  const activeConfig = consentContentData.detail.active_config;
@@ -1173,12 +1223,12 @@ export const RedactoNoticeConsent = ({
1173
1223
  );
1174
1224
  }
1175
1225
  const initialCollapsedState = activeConfig.purposes.reduce(
1176
- (acc, purpose) => ({
1177
- ...acc,
1178
- [purpose.uuid]: true,
1179
- }),
1180
- {}
1181
- );
1226
+ (acc, purpose) => ({
1227
+ ...acc,
1228
+ [purpose.uuid]: true,
1229
+ }),
1230
+ {}
1231
+ );
1182
1232
  setCollapsedPurposes(initialCollapsedState);
1183
1233
 
1184
1234
  // Initialize selected states - handle reconsent pre-population
@@ -1188,28 +1238,28 @@ export const RedactoNoticeConsent = ({
1188
1238
  // Categorize purposes for reconsent UI
1189
1239
  const newCategorizedPurposes = isReconsentRequired
1190
1240
  ? {
1191
- alreadyConsented: activeConfig.purposes.filter(
1192
- (purpose) => {
1193
- const selection = purposeSelections?.[purpose.uuid];
1194
- return (
1195
- selection &&
1196
- (selection.status === "ACTIVE" ||
1197
- selection.status === "EXPIRED") &&
1198
- !selection.needs_reconsent
1199
- );
1200
- }
1201
- ),
1202
- needsConsent: activeConfig.purposes.filter(
1203
- (purpose) => {
1204
- const selection = purposeSelections?.[purpose.uuid];
1205
- return (
1206
- !selection ||
1207
- selection.status !== "ACTIVE" ||
1208
- selection.needs_reconsent
1209
- );
1210
- }
1211
- ),
1212
- }
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
+ }
1213
1263
  : null;
1214
1264
 
1215
1265
  setCategorizedPurposes(newCategorizedPurposes);
@@ -1217,8 +1267,8 @@ export const RedactoNoticeConsent = ({
1217
1267
  const initialPurposeState: Record<string, boolean> =
1218
1268
  activeConfig.purposes.reduce(
1219
1269
  (acc: Record<string, boolean>, purpose) => {
1220
- if (isReconsentRequired && purposeSelections?.[purpose.uuid]) {
1221
- // Pre-populate with existing selection for reconsent
1270
+ if (purposeSelections?.[purpose.uuid]) {
1271
+ // Pre-populate with existing selection (reconsent or magic link flow)
1222
1272
  acc[purpose.uuid] = purposeSelections[purpose.uuid].selected;
1223
1273
  } else {
1224
1274
  // Default to false for new consent
@@ -1236,10 +1286,9 @@ export const RedactoNoticeConsent = ({
1236
1286
  purpose.data_elements.forEach((element) => {
1237
1287
  const combinedId = `${purpose.uuid}-${element.uuid}`;
1238
1288
  if (
1239
- isReconsentRequired &&
1240
1289
  purposeSelections?.[purpose.uuid]?.data_elements?.[element.uuid]
1241
1290
  ) {
1242
- // Pre-populate with existing selection for reconsent
1291
+ // Pre-populate with existing selection (reconsent or magic link flow)
1243
1292
  acc[combinedId] =
1244
1293
  purposeSelections[purpose.uuid].data_elements[
1245
1294
  element.uuid
@@ -1256,6 +1305,7 @@ export const RedactoNoticeConsent = ({
1256
1305
  setSelectedDataElements(initialDataElementState);
1257
1306
  // Store initial selections for already-consented purposes to track modifications
1258
1307
  if (isReconsentRequired && newCategorizedPurposes) {
1308
+ setIsFullyConsentedReviewMode(false);
1259
1309
  const initialSelections: Record<string, boolean> = {};
1260
1310
  newCategorizedPurposes.alreadyConsented.forEach((purpose) => {
1261
1311
  purpose.data_elements.forEach((element) => {
@@ -1272,7 +1322,39 @@ export const RedactoNoticeConsent = ({
1272
1322
  });
1273
1323
  });
1274
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
+ }
1275
1356
  } else {
1357
+ setIsFullyConsentedReviewMode(false);
1276
1358
  setInitialDataElementSelections({});
1277
1359
  }
1278
1360
 
@@ -1315,6 +1397,7 @@ export const RedactoNoticeConsent = ({
1315
1397
  language,
1316
1398
  applicationId,
1317
1399
  validateAgainst,
1400
+ includeFullyConsentedData,
1318
1401
  // Removed onError and isRefreshingToken from dependencies to prevent duplicate API calls
1319
1402
  // onError is only called on errors, not needed for initial data fetching
1320
1403
  ]);
@@ -1453,7 +1536,7 @@ export const RedactoNoticeConsent = ({
1453
1536
  selected: element.required
1454
1537
  ? true
1455
1538
  : selectedDataElements[`${purpose.uuid}-${element.uuid}`] ||
1456
- false,
1539
+ false,
1457
1540
  })),
1458
1541
  };
1459
1542
  }),
@@ -1711,9 +1794,9 @@ export const RedactoNoticeConsent = ({
1711
1794
  // Determine which purposes to include based on UI mode
1712
1795
  const purposesToProcess = categorizedPurposes
1713
1796
  ? [
1714
- ...categorizedPurposes.alreadyConsented,
1715
- ...categorizedPurposes.needsConsent,
1716
- ]
1797
+ ...categorizedPurposes.alreadyConsented,
1798
+ ...categorizedPurposes.needsConsent,
1799
+ ]
1717
1800
  : content.purposes || [];
1718
1801
 
1719
1802
  purposesToProcess.forEach((purpose) => {
@@ -1743,12 +1826,32 @@ export const RedactoNoticeConsent = ({
1743
1826
  }
1744
1827
  });
1745
1828
 
1746
- // 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
1747
1850
  if (ttsAudioUrls.confirm_button_text_audio_url) {
1748
1851
  sequence.push(ttsAudioUrls.confirm_button_text_audio_url);
1749
1852
  }
1750
1853
 
1751
- // 8. Decline button text audio
1854
+ // 11. Decline button text audio
1752
1855
  if (ttsAudioUrls.decline_button_text_audio_url) {
1753
1856
  sequence.push(ttsAudioUrls.decline_button_text_audio_url);
1754
1857
  }
@@ -1790,9 +1893,9 @@ export const RedactoNoticeConsent = ({
1790
1893
  // 6. Purposes and data elements
1791
1894
  const purposesToProcess = categorizedPurposes
1792
1895
  ? [
1793
- ...categorizedPurposes.alreadyConsented,
1794
- ...categorizedPurposes.needsConsent,
1795
- ]
1896
+ ...categorizedPurposes.alreadyConsented,
1897
+ ...categorizedPurposes.needsConsent,
1898
+ ]
1796
1899
  : content.purposes || [];
1797
1900
 
1798
1901
  purposesToProcess.forEach((purpose) => {
@@ -1818,12 +1921,32 @@ export const RedactoNoticeConsent = ({
1818
1921
  }
1819
1922
  });
1820
1923
 
1821
- // 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
1822
1945
  if (ttsAudioUrls.confirm_button_text_audio_url) {
1823
1946
  elementIds.push("confirm-button-text");
1824
1947
  }
1825
1948
 
1826
- // 8. Decline button text audio
1949
+ // 11. Decline button text audio
1827
1950
  if (ttsAudioUrls.decline_button_text_audio_url) {
1828
1951
  elementIds.push("decline-button-text");
1829
1952
  }
@@ -1945,9 +2068,9 @@ export const RedactoNoticeConsent = ({
1945
2068
  // Find which purpose we're on (rough estimate)
1946
2069
  const purposesToProcess = categorizedPurposes
1947
2070
  ? [
1948
- ...categorizedPurposes.alreadyConsented,
1949
- ...categorizedPurposes.needsConsent,
1950
- ]
2071
+ ...categorizedPurposes.alreadyConsented,
2072
+ ...categorizedPurposes.needsConsent,
2073
+ ]
1951
2074
  : content.purposes || [];
1952
2075
 
1953
2076
  let audioOffset = headerAudioCount;
@@ -2016,9 +2139,9 @@ export const RedactoNoticeConsent = ({
2016
2139
  rect.top >= 0 &&
2017
2140
  rect.left >= 0 &&
2018
2141
  rect.bottom <=
2019
- (window.innerHeight || document.documentElement.clientHeight) &&
2142
+ (window.innerHeight || document.documentElement.clientHeight) &&
2020
2143
  rect.right <=
2021
- (window.innerWidth || document.documentElement.clientWidth);
2144
+ (window.innerWidth || document.documentElement.clientWidth);
2022
2145
 
2023
2146
  if (!isVisible) {
2024
2147
  element.scrollIntoView({
@@ -2658,7 +2781,7 @@ export const RedactoNoticeConsent = ({
2658
2781
  {getTranslatedText(
2659
2782
  "privacy_policy_anchor_text",
2660
2783
  content?.privacy_policy_anchor_text ||
2661
- "Privacy Policy"
2784
+ "Privacy Policy"
2662
2785
  )}
2663
2786
  </a>
2664
2787
  </span>
@@ -2739,12 +2862,142 @@ export const RedactoNoticeConsent = ({
2739
2862
  onPurposeCollapse={togglePurposeCollapse}
2740
2863
  onDataElementToggle={handleDataElementCheckboxChange}
2741
2864
  getTranslatedText={getTranslatedText}
2742
- isAlreadyConsented={false}
2865
+ isAlreadyConsented={isFullyConsentedReviewMode}
2743
2866
  selectedLanguage={selectedLanguage}
2867
+ initialDataElementSelections={initialDataElementSelections}
2744
2868
  />
2745
2869
  ))}
2746
2870
  </div>
2747
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
+ )}
2748
3001
  </div>
2749
3002
  <div
2750
3003
  style={{
@@ -2753,53 +3006,72 @@ export const RedactoNoticeConsent = ({
2753
3006
  ...sectionStyle,
2754
3007
  }}
2755
3008
  >
2756
- <button
2757
- ref={lastFocusableRef}
2758
- id="confirm-button-text"
2759
- style={{
2760
- ...styles.button,
2761
- ...responsiveStyles.button,
2762
- ...styles.acceptButton,
2763
- ...acceptButtonStyle,
2764
- opacity: acceptDisabled ? 0.5 : 1,
2765
- }}
2766
- onClick={handleAccept}
2767
- disabled={acceptDisabled || isSubmittingDecline}
2768
- aria-label={getTranslatedText(
2769
- "confirm_button_text",
2770
- content?.confirm_button_text || "Accept"
2771
- )}
2772
- >
2773
- {isSubmittingAccept
2774
- ? "Processing..."
2775
- : 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(
2776
3039
  "confirm_button_text",
2777
3040
  content?.confirm_button_text || "Accept"
2778
3041
  )}
2779
- </button>
2780
- <button
2781
- id="decline-button-text"
2782
- style={{
2783
- ...styles.button,
2784
- ...responsiveStyles.button,
2785
- ...styles.cancelButton,
2786
- ...declineButtonStyle,
2787
- opacity: isSubmittingAccept ? 0.5 : 1,
2788
- }}
2789
- onClick={handleDecline}
2790
- disabled={isSubmittingDecline || isSubmittingAccept}
2791
- aria-label={getTranslatedText(
2792
- "decline_button_text",
2793
- content?.decline_button_text || "Decline"
2794
- )}
2795
- >
2796
- {isSubmittingDecline
2797
- ? "Processing..."
2798
- : 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(
2799
3062
  "decline_button_text",
2800
3063
  content?.decline_button_text || "Decline"
2801
3064
  )}
2802
- </button>
3065
+ >
3066
+ {isSubmittingDecline
3067
+ ? "Processing..."
3068
+ : getTranslatedText(
3069
+ "decline_button_text",
3070
+ content?.decline_button_text || "Decline"
3071
+ )}
3072
+ </button>
3073
+ </>
3074
+ )}
2803
3075
  </div>
2804
3076
  </div>
2805
3077
  )}