@redacto.io/consent-sdk-react 0.0.3 → 1.1.0

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