@redacto.io/consent-sdk-react 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +176 -0
  3. package/dist/index.d.mts +51 -4
  4. package/dist/index.d.ts +51 -4
  5. package/dist/index.js +1912 -345
  6. package/dist/index.mjs +1911 -345
  7. package/dist/privacy-center.d.mts +566 -0
  8. package/dist/privacy-center.d.ts +566 -0
  9. package/dist/privacy-center.js +17174 -0
  10. package/dist/privacy-center.mjs +17136 -0
  11. package/package.json +47 -3
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +201 -118
  13. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1194 -93
  14. package/src/RedactoNoticeConsent/api/index.ts +337 -246
  15. package/src/RedactoNoticeConsent/api/types.ts +140 -41
  16. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +602 -0
  17. package/src/RedactoNoticeConsent/components/index.ts +7 -0
  18. package/src/RedactoNoticeConsent/injectStyles.ts +11 -0
  19. package/src/RedactoNoticeConsent/language-codes.ts +61 -0
  20. package/src/RedactoNoticeConsent/services/storage.ts +122 -0
  21. package/src/RedactoNoticeConsent/types.ts +2 -0
  22. package/src/RedactoNoticeConsentInline/api/index.ts +26 -64
  23. package/src/RedactoNoticeConsentInline/api/types.ts +0 -25
  24. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +19 -0
  25. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +17 -0
  26. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +79 -0
  27. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +27 -0
  28. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +142 -0
  29. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +51 -0
  30. package/src/RedactoPrivacyCenter/api/actions.ts +444 -0
  31. package/src/RedactoPrivacyCenter/api/client.ts +413 -0
  32. package/src/RedactoPrivacyCenter/api/config.ts +33 -0
  33. package/src/RedactoPrivacyCenter/api/fetcher.ts +181 -0
  34. package/src/RedactoPrivacyCenter/api/upload.ts +93 -0
  35. package/src/RedactoPrivacyCenter/assets/character.svg +31 -0
  36. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +9 -0
  37. package/src/RedactoPrivacyCenter/assets/greenTick.svg +4 -0
  38. package/src/RedactoPrivacyCenter/assets/mail.svg +3 -0
  39. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +9 -0
  40. package/src/RedactoPrivacyCenter/assets/sentImg.svg +9 -0
  41. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +9 -0
  42. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +272 -0
  43. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +468 -0
  44. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +433 -0
  45. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +726 -0
  46. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +239 -0
  47. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +7 -0
  48. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +155 -0
  49. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +189 -0
  50. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +118 -0
  51. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +66 -0
  52. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +305 -0
  53. package/src/RedactoPrivacyCenter/components/Form.tsx +1455 -0
  54. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +176 -0
  55. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +330 -0
  56. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +329 -0
  57. package/src/RedactoPrivacyCenter/components/Pagination.tsx +140 -0
  58. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +302 -0
  59. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +218 -0
  60. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +475 -0
  61. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +412 -0
  62. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +42 -0
  63. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +1 -0
  64. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +40 -0
  65. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +38 -0
  66. package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +85 -0
  67. package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +141 -0
  68. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +179 -0
  69. package/src/RedactoPrivacyCenter/components/constant.ts +20 -0
  70. package/src/RedactoPrivacyCenter/components/types.ts +68 -0
  71. package/src/RedactoPrivacyCenter/components/utils.tsx +10 -0
  72. package/src/RedactoPrivacyCenter/config/routes.ts +9 -0
  73. package/src/RedactoPrivacyCenter/config/table.ts +25 -0
  74. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +123 -0
  75. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +44 -0
  76. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +11 -0
  77. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +64 -0
  78. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +30 -0
  79. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +60 -0
  80. package/src/RedactoPrivacyCenter/context/theme.tsx +127 -0
  81. package/src/RedactoPrivacyCenter/lib/api.ts +468 -0
  82. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +63 -0
  83. package/src/RedactoPrivacyCenter/lib/constants.ts +116 -0
  84. package/src/RedactoPrivacyCenter/lib/i18n.ts +81 -0
  85. package/src/RedactoPrivacyCenter/lib/locales.ts +105 -0
  86. package/src/RedactoPrivacyCenter/lib/queries.ts +30 -0
  87. package/src/RedactoPrivacyCenter/lib/types.ts +346 -0
  88. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +37 -0
  89. package/src/RedactoPrivacyCenter/lib/utils.ts +625 -0
  90. package/src/RedactoPrivacyCenter/locales/as/translation.json +188 -0
  91. package/src/RedactoPrivacyCenter/locales/bho/translation.json +188 -0
  92. package/src/RedactoPrivacyCenter/locales/bn/translation.json +188 -0
  93. package/src/RedactoPrivacyCenter/locales/brx/translation.json +188 -0
  94. package/src/RedactoPrivacyCenter/locales/doi/translation.json +188 -0
  95. package/src/RedactoPrivacyCenter/locales/en/translation.json +188 -0
  96. package/src/RedactoPrivacyCenter/locales/gom/translation.json +188 -0
  97. package/src/RedactoPrivacyCenter/locales/gu/translation.json +188 -0
  98. package/src/RedactoPrivacyCenter/locales/hi/translation.json +188 -0
  99. package/src/RedactoPrivacyCenter/locales/kn/translation.json +188 -0
  100. package/src/RedactoPrivacyCenter/locales/ks/translation.json +188 -0
  101. package/src/RedactoPrivacyCenter/locales/mai/translation.json +188 -0
  102. package/src/RedactoPrivacyCenter/locales/ml/translation.json +188 -0
  103. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +188 -0
  104. package/src/RedactoPrivacyCenter/locales/mr/translation.json +188 -0
  105. package/src/RedactoPrivacyCenter/locales/ne/translation.json +188 -0
  106. package/src/RedactoPrivacyCenter/locales/or/translation.json +188 -0
  107. package/src/RedactoPrivacyCenter/locales/pa/translation.json +188 -0
  108. package/src/RedactoPrivacyCenter/locales/sa/translation.json +188 -0
  109. package/src/RedactoPrivacyCenter/locales/sat/translation.json +188 -0
  110. package/src/RedactoPrivacyCenter/locales/sd/translation.json +188 -0
  111. package/src/RedactoPrivacyCenter/locales/ta/translation.json +188 -0
  112. package/src/RedactoPrivacyCenter/locales/te/translation.json +188 -0
  113. package/src/RedactoPrivacyCenter/locales/ur/translation.json +188 -0
  114. package/src/RedactoPrivacyCenter/styles/base.css +117 -0
  115. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1000 -0
  116. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +819 -0
  117. package/src/RedactoPrivacyCenter/styles/responsive.ts +138 -0
  118. package/src/RedactoPrivacyCenter/styles/variables.css +133 -0
  119. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +365 -0
  120. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +479 -0
  121. package/src/RedactoPrivacyCenter/table/index.ts +2 -0
  122. package/src/RedactoPrivacyCenter/types/images.d.ts +29 -0
  123. package/src/RedactoPrivacyCenter/ui/Footer.tsx +73 -0
  124. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +54 -0
  125. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +87 -0
  126. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +47 -0
  127. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +69 -0
  128. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +48 -0
  129. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +77 -0
  130. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +55 -0
  131. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +97 -0
  132. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +35 -0
  133. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +128 -0
  134. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +30 -0
  135. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +104 -0
  136. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +175 -0
  137. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +381 -0
  138. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +137 -0
  139. package/src/RedactoPrivacyCenter/ui/PCText.tsx +39 -0
  140. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +43 -0
  141. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +37 -0
  142. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +119 -0
  143. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +25 -0
  144. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +55 -0
  145. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +70 -0
  146. package/src/RedactoPrivacyCenter/ui/index.ts +18 -0
  147. package/src/RedactoPrivacyCenter/utils/cn.ts +28 -0
  148. package/src/RedactoPrivacyCenter/utils/date.ts +48 -0
  149. package/src/RedactoPrivacyCenter/utils/status.ts +111 -0
  150. package/src/RedactoPrivacyCenter/utils/toast.ts +130 -0
  151. package/src/index.ts +10 -0
  152. package/src/privacy-center.ts +107 -0
  153. package/tsconfig.json +2 -0
  154. package/tsup.config.ts +16 -1
  155. package/src/utils/isLegacyApi.ts +0 -12
@@ -14,8 +14,9 @@ import {
14
14
  clearApiCache,
15
15
  fetchConsentContent,
16
16
  fetchTTSAudioUrls,
17
+ initiateGuardianVerification,
17
18
  submitConsentEvent,
18
- submitGuardianInfo,
19
+ verifyGuardianStatus,
19
20
  } from "./api";
20
21
 
21
22
  // Type imports
@@ -31,12 +32,16 @@ import logo from "./assets/redacto-logo.png";
31
32
 
32
33
  // Constants
33
34
  import { DPO_AUDIO_ELEMENT_MAP } from "./constants";
35
+ import { toBcp47Code } from "./language-codes";
34
36
 
35
37
  // Utility imports
36
38
  import { injectCheckboxStyles } from "./injectStyles";
37
39
  import { styles } from "./styles";
38
40
  import { useMediaQuery } from "./useMediaQuery";
39
41
 
42
+ // Storage service for redirect flow
43
+ import { clearConsentState, getConsentState, saveConsentState } from "./services/storage";
44
+
40
45
  // =============================================================================
41
46
  // SUB-COMPONENTS
42
47
  // =============================================================================
@@ -355,6 +360,13 @@ const PurposeItem: React.FC<
355
360
  *
356
361
  * Displays a form for collecting guardian information for minor consent.
357
362
  */
363
+ const RELATIONSHIP_OPTIONS = [
364
+ { value: "Father", label: "Father" },
365
+ { value: "Mother", label: "Mother" },
366
+ { value: "Legal Guardian", label: "Legal Guardian" },
367
+ { value: "Grandparent", label: "Grandparent" },
368
+ ];
369
+
358
370
  const GuardianForm: React.FC<{
359
371
  formData: {
360
372
  guardianName: string;
@@ -390,39 +402,144 @@ const GuardianForm: React.FC<{
390
402
  declineButtonStyle,
391
403
  logoUrl,
392
404
  }) => {
405
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
406
+ const [focusedIndex, setFocusedIndex] = useState(-1);
407
+ const [openUpward, setOpenUpward] = useState(false);
408
+ const dropdownRef = useRef<HTMLDivElement>(null);
409
+ const optionsRef = useRef<HTMLDivElement>(null);
410
+
411
+ // Close dropdown on outside click
412
+ useEffect(() => {
413
+ if (!isDropdownOpen) return;
414
+ const handleClickOutside = (e: MouseEvent) => {
415
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
416
+ setIsDropdownOpen(false);
417
+ setFocusedIndex(-1);
418
+ }
419
+ };
420
+ document.addEventListener("mousedown", handleClickOutside);
421
+ return () => document.removeEventListener("mousedown", handleClickOutside);
422
+ }, [isDropdownOpen]);
423
+
424
+ // Determine direction on open
425
+ useEffect(() => {
426
+ if (!isDropdownOpen || !dropdownRef.current) return;
427
+ const rect = dropdownRef.current.getBoundingClientRect();
428
+ const spaceBelow = window.innerHeight - rect.bottom;
429
+ const dropdownHeight = 220; // approximate max height of options panel
430
+ setOpenUpward(spaceBelow < dropdownHeight && rect.top > dropdownHeight);
431
+ }, [isDropdownOpen]);
432
+
433
+ const handleSelect = (value: string) => {
434
+ onChange("guardianRelationship", value);
435
+ setIsDropdownOpen(false);
436
+ setFocusedIndex(-1);
437
+ };
438
+
439
+ const handleKeyDown = (e: React.KeyboardEvent) => {
440
+ if (!isDropdownOpen) {
441
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
442
+ e.preventDefault();
443
+ setIsDropdownOpen(true);
444
+ setFocusedIndex(0);
445
+ }
446
+ return;
447
+ }
448
+ switch (e.key) {
449
+ case "ArrowDown":
450
+ e.preventDefault();
451
+ setFocusedIndex((prev) => Math.min(prev + 1, RELATIONSHIP_OPTIONS.length - 1));
452
+ break;
453
+ case "ArrowUp":
454
+ e.preventDefault();
455
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
456
+ break;
457
+ case "Enter":
458
+ case " ":
459
+ e.preventDefault();
460
+ if (focusedIndex >= 0) handleSelect(RELATIONSHIP_OPTIONS[focusedIndex].value);
461
+ break;
462
+ case "Escape":
463
+ case "Tab":
464
+ setIsDropdownOpen(false);
465
+ setFocusedIndex(-1);
466
+ break;
467
+ }
468
+ };
469
+
470
+ // Scroll focused option into view
471
+ useEffect(() => {
472
+ if (focusedIndex >= 0 && optionsRef.current) {
473
+ const option = optionsRef.current.children[focusedIndex] as HTMLElement;
474
+ option?.scrollIntoView?.({ block: "nearest" });
475
+ }
476
+ }, [focusedIndex]);
477
+
478
+ const borderColor = settings?.borderColor || "#d0d5dd";
479
+ const textColor = settings?.textColor || "#344054";
480
+ const bgColor = settings?.backgroundColor || "#ffffff";
481
+ const radius = settings?.borderRadius || "8px";
482
+ const font = settings?.font || "inherit";
483
+
393
484
  const inputStyle = {
394
485
  width: "100%",
395
486
  padding: "10px 12px",
396
- border: `1px solid ${settings?.borderColor || "#d0d5dd"}`,
397
- borderRadius: settings?.borderRadius || "8px",
487
+ border: `1px solid ${borderColor}`,
488
+ borderRadius: radius,
398
489
  fontSize: "13px",
399
- fontFamily: settings?.font || "inherit",
400
- color: settings?.textColor || "#344054",
401
- backgroundColor: settings?.backgroundColor || "#ffffff",
402
- marginBottom: "4px",
490
+ fontFamily: font,
491
+ color: textColor,
492
+ backgroundColor: bgColor,
493
+ marginBottom: "2px",
403
494
  boxSizing: "border-box" as const,
495
+ outline: "none",
404
496
  };
405
497
 
406
498
  const errorStyle = {
407
499
  color: "#DC2626",
408
- fontSize: "12px",
500
+ fontSize: "11px",
409
501
  marginTop: "4px",
410
- marginBottom: "16px",
502
+ marginBottom: "0px",
411
503
  };
412
504
 
413
505
  const labelStyle = {
414
506
  ...componentTextStyle,
415
507
  fontSize: "13px",
416
- fontWeight: "500",
417
- marginBottom: "6px",
508
+ fontWeight: "500" as const,
509
+ marginBottom: "4px",
418
510
  display: "block",
419
511
  };
420
512
 
513
+ const hintStyle = {
514
+ fontSize: "11px",
515
+ color: "#667085",
516
+ marginTop: "2px",
517
+ marginBottom: "0px",
518
+ lineHeight: "1.4",
519
+ };
520
+
521
+ const infoBannerStyle = {
522
+ display: "flex",
523
+ alignItems: "flex-start",
524
+ gap: "8px",
525
+ padding: "10px 12px",
526
+ backgroundColor: "#EFF6FF",
527
+ border: "1px solid #BFDBFE",
528
+ borderRadius: radius,
529
+ marginBottom: "16px",
530
+ fontSize: "12px",
531
+ lineHeight: "1.5",
532
+ color: "#1E40AF",
533
+ };
534
+
535
+ const selectedLabel = RELATIONSHIP_OPTIONS.find((o) => o.value === formData.guardianRelationship)?.label;
536
+ const hasError = Boolean(errors.guardianRelationship);
537
+
421
538
  return (
422
539
  <>
423
540
  <div style={{ ...styles.topSection, ...componentTextStyle }}>
424
541
  <div style={styles.topLeft}>
425
- <img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
542
+ <img style={styles.logo} src={logoUrl} alt="Logo" />
426
543
  <h2
427
544
  id="guardian-form-title"
428
545
  style={{
@@ -433,7 +550,7 @@ const GuardianForm: React.FC<{
433
550
  fontWeight: 600,
434
551
  }}
435
552
  >
436
- Guardian Information Required
553
+ Guardian Verification
437
554
  </h2>
438
555
  </div>
439
556
  </div>
@@ -450,28 +567,46 @@ const GuardianForm: React.FC<{
450
567
  ...styles.privacyText,
451
568
  ...responsiveStyles.privacyText,
452
569
  ...componentTextStyle,
453
- fontSize: "14px",
454
- lineHeight: "1.4",
570
+ fontSize: "13px",
571
+ lineHeight: "1.5",
572
+ marginBottom: "12px",
455
573
  }}
456
574
  >
457
- As you are under the age of consent, we need to collect information
458
- about your legal guardian to proceed.
575
+ Since you are under the age of consent, a legal guardian must verify
576
+ their identity via DigiLocker before you can proceed.
459
577
  </p>
460
578
 
579
+ <div style={infoBannerStyle}>
580
+ <svg
581
+ width="16"
582
+ height="16"
583
+ viewBox="0 0 24 24"
584
+ fill="none"
585
+ stroke="#2563EB"
586
+ strokeWidth="2"
587
+ strokeLinecap="round"
588
+ strokeLinejoin="round"
589
+ style={{ flexShrink: 0, marginTop: "1px" }}
590
+ >
591
+ <circle cx="12" cy="12" r="10" />
592
+ <line x1="12" y1="16" x2="12" y2="12" />
593
+ <line x1="12" y1="8" x2="12.01" y2="8" />
594
+ </svg>
595
+ <span>
596
+ The guardian's name will be matched against their DigiLocker identity document (e.g. Aadhaar). Please enter the name exactly as it appears on the document.
597
+ </span>
598
+ </div>
599
+
461
600
  <div
462
601
  style={{
463
602
  display: "flex",
464
603
  flexDirection: "column",
465
- gap: "12px",
466
- marginTop: "16px",
604
+ gap: "14px",
467
605
  }}
468
606
  >
469
607
  <div>
470
- <label
471
- htmlFor="guardianName"
472
- style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
473
- >
474
- Name of Guardian *
608
+ <label htmlFor="guardianName" style={labelStyle}>
609
+ Guardian's Full Name <span style={{ color: "#DC2626" }}>*</span>
475
610
  </label>
476
611
  <input
477
612
  id="guardianName"
@@ -480,64 +615,171 @@ const GuardianForm: React.FC<{
480
615
  onChange={(e) => onChange("guardianName", e.target.value)}
481
616
  style={{
482
617
  ...inputStyle,
483
- borderColor: errors.guardianName
484
- ? "#DC2626"
485
- : inputStyle.border,
618
+ borderColor: errors.guardianName ? "#DC2626" : borderColor,
486
619
  }}
487
- placeholder="Enter guardian's full name"
620
+ placeholder="e.g. Rajesh Kumar"
621
+ autoComplete="name"
622
+ aria-describedby="guardianName-hint"
488
623
  />
624
+ <p id="guardianName-hint" style={hintStyle}>
625
+ Enter the full name as per government ID (Aadhaar, PAN, etc.)
626
+ </p>
489
627
  {errors.guardianName && (
490
- <div style={errorStyle}>{errors.guardianName}</div>
628
+ <div style={errorStyle} role="alert">{errors.guardianName}</div>
491
629
  )}
492
630
  </div>
493
631
 
494
632
  <div>
495
- <label
496
- htmlFor="guardianContact"
497
- style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
498
- >
499
- Contact of Guardian *
633
+ <label htmlFor="guardianContact" style={labelStyle}>
634
+ Guardian's Phone Number <span style={{ color: "#DC2626" }}>*</span>
500
635
  </label>
501
636
  <input
502
637
  id="guardianContact"
503
- type="text"
638
+ type="tel"
639
+ inputMode="tel"
504
640
  value={formData.guardianContact}
505
641
  onChange={(e) => onChange("guardianContact", e.target.value)}
506
642
  style={{
507
643
  ...inputStyle,
508
- borderColor: errors.guardianContact
509
- ? "#DC2626"
510
- : inputStyle.border,
644
+ borderColor: errors.guardianContact ? "#DC2626" : borderColor,
511
645
  }}
512
- placeholder="Enter guardian's email or phone number"
646
+ placeholder="e.g. 9876543210"
647
+ autoComplete="tel"
648
+ aria-describedby="guardianContact-hint"
513
649
  />
650
+ <p id="guardianContact-hint" style={hintStyle}>
651
+ Mobile number linked to guardian's Aadhaar
652
+ </p>
514
653
  {errors.guardianContact && (
515
- <div style={errorStyle}>{errors.guardianContact}</div>
654
+ <div style={errorStyle} role="alert">{errors.guardianContact}</div>
516
655
  )}
517
656
  </div>
518
657
 
519
- <div>
520
- <label
521
- htmlFor="guardianRelationship"
522
- style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
523
- >
524
- Relationship to Guardian *
658
+ <div ref={dropdownRef} style={{ position: "relative" }}>
659
+ <label id="guardianRelationship-label" style={labelStyle}>
660
+ Relationship <span style={{ color: "#DC2626" }}>*</span>
525
661
  </label>
526
- <input
527
- id="guardianRelationship"
528
- type="text"
529
- value={formData.guardianRelationship}
530
- onChange={(e) => onChange("guardianRelationship", e.target.value)}
662
+ <div
663
+ role="combobox"
664
+ aria-expanded={isDropdownOpen}
665
+ aria-haspopup="listbox"
666
+ aria-labelledby="guardianRelationship-label"
667
+ aria-controls="guardianRelationship-listbox"
668
+ aria-activedescendant={focusedIndex >= 0 ? `guardianRelationship-option-${focusedIndex}` : undefined}
669
+ tabIndex={0}
670
+ onClick={() => { setIsDropdownOpen((prev) => !prev); setFocusedIndex(-1); }}
671
+ onKeyDown={handleKeyDown}
531
672
  style={{
532
673
  ...inputStyle,
533
- borderColor: errors.guardianRelationship
534
- ? "#DC2626"
535
- : inputStyle.border,
674
+ borderColor: hasError ? "#DC2626" : isDropdownOpen ? (settings?.button?.accept?.backgroundColor || "#3b82f6") : borderColor,
675
+ boxShadow: isDropdownOpen ? `0 0 0 1px ${settings?.button?.accept?.backgroundColor || "#3b82f6"}` : "none",
676
+ color: selectedLabel ? textColor : "#9CA3AF",
677
+ cursor: "pointer",
678
+ display: "flex",
679
+ alignItems: "center",
680
+ justifyContent: "space-between",
681
+ userSelect: "none",
682
+ transition: "border-color 0.15s, box-shadow 0.15s",
536
683
  }}
537
- placeholder="e.g., Parent, Legal Guardian, etc."
538
- />
684
+ >
685
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
686
+ {selectedLabel || "Select relationship"}
687
+ </span>
688
+ <svg
689
+ width="14"
690
+ height="14"
691
+ viewBox="0 0 24 24"
692
+ fill="none"
693
+ stroke="#667085"
694
+ strokeWidth="2"
695
+ strokeLinecap="round"
696
+ strokeLinejoin="round"
697
+ style={{
698
+ flexShrink: 0,
699
+ marginLeft: "8px",
700
+ transform: isDropdownOpen ? "rotate(180deg)" : "rotate(0deg)",
701
+ transition: "transform 0.2s",
702
+ }}
703
+ >
704
+ <polyline points="6 9 12 15 18 9" />
705
+ </svg>
706
+ </div>
707
+
708
+ {isDropdownOpen && (
709
+ <div
710
+ ref={optionsRef}
711
+ id="guardianRelationship-listbox"
712
+ role="listbox"
713
+ aria-labelledby="guardianRelationship-label"
714
+ style={{
715
+ position: "absolute",
716
+ ...(openUpward
717
+ ? { bottom: "calc(100% + 4px)" }
718
+ : { top: "calc(100% + 4px)" }),
719
+ left: 0,
720
+ right: 0,
721
+ backgroundColor: bgColor,
722
+ border: `1px solid ${borderColor}`,
723
+ borderRadius: radius,
724
+ boxShadow: openUpward
725
+ ? "0 -4px 12px rgba(0, 0, 0, 0.1), 0 -1px 3px rgba(0, 0, 0, 0.06)"
726
+ : "0 4px 12px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06)",
727
+ zIndex: 50,
728
+ overflow: "hidden",
729
+ padding: "4px 0",
730
+ maxHeight: "220px",
731
+ overflowY: "auto",
732
+ }}
733
+ >
734
+ {RELATIONSHIP_OPTIONS.map((option, index) => {
735
+ const isSelected = formData.guardianRelationship === option.value;
736
+ const isFocused = focusedIndex === index;
737
+ const accentColor = settings?.button?.accept?.backgroundColor || "#3b82f6";
738
+ return (
739
+ <div
740
+ key={option.value}
741
+ id={`guardianRelationship-option-${index}`}
742
+ role="option"
743
+ aria-selected={isSelected}
744
+ onClick={() => handleSelect(option.value)}
745
+ onMouseEnter={() => setFocusedIndex(index)}
746
+ style={{
747
+ padding: "9px 12px",
748
+ fontSize: "13px",
749
+ fontFamily: font,
750
+ color: isSelected ? accentColor : textColor,
751
+ backgroundColor: isFocused ? "#F3F4F6" : "transparent",
752
+ cursor: "pointer",
753
+ display: "flex",
754
+ alignItems: "center",
755
+ justifyContent: "space-between",
756
+ transition: "background-color 0.1s",
757
+ }}
758
+ >
759
+ <span>{option.label}</span>
760
+ {isSelected && (
761
+ <svg
762
+ width="14"
763
+ height="14"
764
+ viewBox="0 0 24 24"
765
+ fill="none"
766
+ stroke={accentColor}
767
+ strokeWidth="2.5"
768
+ strokeLinecap="round"
769
+ strokeLinejoin="round"
770
+ style={{ flexShrink: 0 }}
771
+ >
772
+ <polyline points="20 6 9 17 4 12" />
773
+ </svg>
774
+ )}
775
+ </div>
776
+ );
777
+ })}
778
+ </div>
779
+ )}
780
+
539
781
  {errors.guardianRelationship && (
540
- <div style={errorStyle}>{errors.guardianRelationship}</div>
782
+ <div style={errorStyle} role="alert">{errors.guardianRelationship}</div>
541
783
  )}
542
784
  </div>
543
785
  </div>
@@ -547,7 +789,7 @@ const GuardianForm: React.FC<{
547
789
  role="alert"
548
790
  style={{
549
791
  color: "#DC2626",
550
- fontSize: "14px",
792
+ fontSize: "13px",
551
793
  marginTop: "16px",
552
794
  padding: "12px",
553
795
  borderRadius: "6px",
@@ -581,11 +823,11 @@ const GuardianForm: React.FC<{
581
823
  type="button"
582
824
  aria-label={
583
825
  isSubmitting
584
- ? "Submitting guardian information"
585
- : "Submit guardian information and proceed"
826
+ ? "Verifying guardian identity"
827
+ : "Proceed to guardian verification"
586
828
  }
587
829
  >
588
- {isSubmitting ? "Submitting..." : "Next"}
830
+ {isSubmitting ? "Verifying..." : "Verify via DigiLocker"}
589
831
  </button>
590
832
  <button
591
833
  style={{
@@ -596,7 +838,7 @@ const GuardianForm: React.FC<{
596
838
  }}
597
839
  onClick={onDecline}
598
840
  type="button"
599
- aria-label="Cancel guardian information form"
841
+ aria-label="Cancel"
600
842
  >
601
843
  Cancel
602
844
  </button>
@@ -605,6 +847,292 @@ const GuardianForm: React.FC<{
605
847
  );
606
848
  };
607
849
 
850
+ /**
851
+ * VerificationScreen Component
852
+ *
853
+ * Displays verification status while DigiLocker verification is in progress.
854
+ * No longer requires document type selection - handled by backend.
855
+ */
856
+ const VerificationScreen: React.FC<{
857
+ onBack: (clearName?: boolean) => void;
858
+ onContinue: () => void;
859
+ isInitiating: boolean;
860
+ isPolling: boolean;
861
+ isVerificationComplete: boolean;
862
+ isAutoTransitioning: boolean;
863
+ error: string | null;
864
+ errorCode: string | null;
865
+ canRetry: boolean;
866
+ styles: any;
867
+ responsiveStyles: any;
868
+ settings?: any;
869
+ componentHeadingStyle: any;
870
+ componentTextStyle: any;
871
+ acceptButtonStyle: any;
872
+ declineButtonStyle: any;
873
+ logoUrl: string;
874
+ }> = ({
875
+ onBack,
876
+ onContinue,
877
+ isInitiating,
878
+ isPolling,
879
+ isVerificationComplete,
880
+ isAutoTransitioning,
881
+ error,
882
+ errorCode,
883
+ canRetry,
884
+ styles,
885
+ responsiveStyles,
886
+ settings,
887
+ componentHeadingStyle,
888
+ componentTextStyle,
889
+ acceptButtonStyle,
890
+ declineButtonStyle,
891
+ logoUrl,
892
+ }) => {
893
+ const errorStyle = {
894
+ color: "#DC2626",
895
+ fontSize: "14px",
896
+ marginTop: "16px",
897
+ padding: "12px",
898
+ borderRadius: "6px",
899
+ border: "1px solid #FCA5A5",
900
+ backgroundColor: "#FEF2F2",
901
+ textAlign: "center" as const,
902
+ };
903
+
904
+ const statusStyle = {
905
+ color: "#059669",
906
+ fontSize: "14px",
907
+ marginTop: "16px",
908
+ padding: "12px",
909
+ borderRadius: "6px",
910
+ border: "1px solid #A7F3D0",
911
+ backgroundColor: "#D1FAE5",
912
+ textAlign: "center" as const,
913
+ };
914
+
915
+ const spinnerStyle = {
916
+ width: "48px",
917
+ height: "48px",
918
+ border: "4px solid #e5e7eb",
919
+ borderTopColor: settings?.button?.accept?.backgroundColor || "#3b82f6",
920
+ borderRadius: "50%",
921
+ animation: "spin 1s linear infinite",
922
+ margin: "24px auto",
923
+ };
924
+
925
+ return (
926
+ <>
927
+ <div style={{ ...styles.topSection, ...componentTextStyle }}>
928
+ <div style={styles.topLeft}>
929
+ <img style={styles.logo} src={logoUrl} alt="Logo" />
930
+ <h2
931
+ id="verification-title"
932
+ style={{
933
+ ...styles.title,
934
+ ...responsiveStyles.title,
935
+ ...componentHeadingStyle,
936
+ fontSize: "16px",
937
+ fontWeight: 600,
938
+ }}
939
+ >
940
+ Guardian Verification
941
+ </h2>
942
+ </div>
943
+ </div>
944
+
945
+ <div
946
+ style={{
947
+ ...styles.middleSection,
948
+ ...responsiveStyles.middleSection,
949
+ ...componentTextStyle,
950
+ textAlign: "center" as const,
951
+ }}
952
+ >
953
+ {isVerificationComplete && !error ? (
954
+ <div
955
+ style={{
956
+ display: "flex",
957
+ flexDirection: "column",
958
+ alignItems: "center",
959
+ justifyContent: "center",
960
+ flex: 1,
961
+ }}
962
+ >
963
+ <div
964
+ style={{
965
+ width: "64px",
966
+ height: "64px",
967
+ borderRadius: "50%",
968
+ backgroundColor: "#D1FAE5",
969
+ display: "flex",
970
+ alignItems: "center",
971
+ justifyContent: "center",
972
+ }}
973
+ >
974
+ <svg
975
+ width="32"
976
+ height="32"
977
+ viewBox="0 0 24 24"
978
+ fill="none"
979
+ stroke="#059669"
980
+ strokeWidth="2"
981
+ strokeLinecap="round"
982
+ strokeLinejoin="round"
983
+ >
984
+ <polyline points="20 6 9 17 4 12" />
985
+ </svg>
986
+ </div>
987
+ <p
988
+ style={{
989
+ ...componentHeadingStyle,
990
+ fontSize: "16px",
991
+ fontWeight: 600,
992
+ lineHeight: "1.5",
993
+ marginTop: "12px",
994
+ marginBottom: "24px",
995
+ }}
996
+ >
997
+ Verification Completed
998
+ </p>
999
+ </div>
1000
+ ) : (isInitiating || isPolling) && !error ? (
1001
+ <>
1002
+ <div style={spinnerStyle} />
1003
+ <div style={statusStyle}>
1004
+ {isInitiating
1005
+ ? "Opening DigiLocker in a new tab..."
1006
+ : "Please complete the verification in the DigiLocker tab. This page will update automatically when verification is complete."}
1007
+ </div>
1008
+ </>
1009
+ ) : null}
1010
+
1011
+ {error && (
1012
+ <>
1013
+ <div
1014
+ style={{
1015
+ width: "64px",
1016
+ height: "64px",
1017
+ borderRadius: "50%",
1018
+ backgroundColor: "#fee2e2",
1019
+ display: "flex",
1020
+ alignItems: "center",
1021
+ justifyContent: "center",
1022
+ margin: "24px auto",
1023
+ }}
1024
+ >
1025
+ <svg
1026
+ width="32"
1027
+ height="32"
1028
+ viewBox="0 0 24 24"
1029
+ fill="none"
1030
+ stroke="#dc2626"
1031
+ strokeWidth="2"
1032
+ strokeLinecap="round"
1033
+ strokeLinejoin="round"
1034
+ >
1035
+ <line x1="18" y1="6" x2="6" y2="18" />
1036
+ <line x1="6" y1="6" x2="18" y2="18" />
1037
+ </svg>
1038
+ </div>
1039
+ <p
1040
+ style={{
1041
+ ...styles.privacyText,
1042
+ ...responsiveStyles.privacyText,
1043
+ ...componentTextStyle,
1044
+ fontSize: "14px",
1045
+ lineHeight: "1.5",
1046
+ marginBottom: "12px",
1047
+ }}
1048
+ >
1049
+ Verification failed
1050
+ </p>
1051
+ <div style={errorStyle}>{error}</div>
1052
+ </>
1053
+ )}
1054
+ </div>
1055
+
1056
+ {isAutoTransitioning ? null : (
1057
+ <div
1058
+ style={{
1059
+ ...styles.bottomSection,
1060
+ ...responsiveStyles.bottomSection,
1061
+ ...componentTextStyle,
1062
+ paddingTop: "12px",
1063
+ display: "flex",
1064
+ gap: "12px",
1065
+ justifyContent: "center",
1066
+ }}
1067
+ >
1068
+ {isVerificationComplete && !error ? (
1069
+ <button
1070
+ style={{
1071
+ ...styles.button,
1072
+ ...responsiveStyles.button,
1073
+ ...acceptButtonStyle,
1074
+ }}
1075
+ onClick={onContinue}
1076
+ type="button"
1077
+ aria-label="Continue to consent form"
1078
+ >
1079
+ Continue
1080
+ </button>
1081
+ ) : error && canRetry ? (
1082
+ <button
1083
+ style={{
1084
+ ...styles.button,
1085
+ ...responsiveStyles.button,
1086
+ ...styles.acceptButton,
1087
+ ...acceptButtonStyle,
1088
+ }}
1089
+ onClick={() => onBack(errorCode === "GUARDIAN_UNDER_18")}
1090
+ type="button"
1091
+ aria-label={
1092
+ errorCode === "GUARDIAN_UNDER_18"
1093
+ ? "Change guardian details"
1094
+ : "Try verification again"
1095
+ }
1096
+ >
1097
+ {errorCode === "GUARDIAN_UNDER_18"
1098
+ ? "Change Guardian"
1099
+ : "Try Again"}
1100
+ </button>
1101
+ ) : error ? (
1102
+ <button
1103
+ style={{
1104
+ ...styles.button,
1105
+ ...responsiveStyles.button,
1106
+ ...styles.cancelButton,
1107
+ ...declineButtonStyle,
1108
+ }}
1109
+ onClick={() => onBack(false)}
1110
+ type="button"
1111
+ aria-label="Go back"
1112
+ >
1113
+ Go Back
1114
+ </button>
1115
+ ) : (
1116
+ <button
1117
+ style={{
1118
+ ...styles.button,
1119
+ ...responsiveStyles.button,
1120
+ ...styles.cancelButton,
1121
+ ...declineButtonStyle,
1122
+ }}
1123
+ onClick={() => onBack(false)}
1124
+ type="button"
1125
+ aria-label="Cancel verification"
1126
+ >
1127
+ Cancel
1128
+ </button>
1129
+ )}
1130
+ </div>
1131
+ )}
1132
+ </>
1133
+ );
1134
+ };
1135
+
608
1136
  /**
609
1137
  * AgeVerification Component
610
1138
  *
@@ -776,6 +1304,8 @@ export const RedactoNoticeConsent = ({
776
1304
  settings,
777
1305
  applicationId,
778
1306
  validateAgainst = "all",
1307
+ digilockerMode = "redirect", // Default to full page redirect (DigiLocker blocks iframe embedding)
1308
+ digilockerCallbackUrl = "/digilocker-callback", // Default callback path
779
1309
  includeFullyConsentedData = false,
780
1310
  reviewModeButtonText = "Continue",
781
1311
  }: Props) => {
@@ -869,6 +1399,8 @@ export const RedactoNoticeConsent = ({
869
1399
 
870
1400
  // Age verification states
871
1401
  const [showAgeVerification, setShowAgeVerification] = useState(false);
1402
+ const [isMinorFlow, setIsMinorFlow] = useState(false);
1403
+ const [selfDeclaredAdult, setSelfDeclaredAdult] = useState(false);
872
1404
 
873
1405
  // Guardian form states
874
1406
  const [showGuardianForm, setShowGuardianForm] = useState(false);
@@ -882,13 +1414,36 @@ export const RedactoNoticeConsent = ({
882
1414
  Record<string, string>
883
1415
  >({});
884
1416
 
1417
+ // Guardian verification states
1418
+ const [showVerificationScreen, setShowVerificationScreen] = useState(false);
1419
+ const [verificationReference, setVerificationReference] = useState<string | null>(null);
1420
+ const [isInitiatingVerification, setIsInitiatingVerification] =
1421
+ useState(false);
1422
+ const [verificationError, setVerificationError] = useState<string | null>(
1423
+ null
1424
+ );
1425
+ const [verificationErrorCode, setVerificationErrorCode] = useState<string | null>(null);
1426
+ const [canRetryVerification, setCanRetryVerification] = useState(false);
1427
+ const [isPollingStatus, setIsPollingStatus] = useState(false);
1428
+ const [isVerificationComplete, setIsVerificationComplete] = useState(false);
1429
+ const [isAutoTransitioning, setIsAutoTransitioning] = useState(false);
1430
+
885
1431
  // Refs and constants
886
1432
  const modalRef = useRef<HTMLDivElement>(null);
887
1433
  const firstFocusableRef = useRef<HTMLButtonElement>(null);
888
1434
  const lastFocusableRef = useRef<HTMLButtonElement>(null);
889
1435
  const abortControllerRef = useRef<AbortController | null>(null);
890
- const isRequestInProgressRef = useRef(false); // Prevent rapid successive calls
1436
+ const isRequestInProgressRef = useRef(false);
891
1437
  const currentAudioRef = useRef<HTMLAudioElement | null>(null);
1438
+ const popupCheckIntervalRef = useRef<number | null>(null);
1439
+ const statusPollIntervalRef = useRef<number | null>(null);
1440
+ const verificationPopupRef = useRef<Window | null>(null);
1441
+ const pollingCleanupRef = useRef<(() => void) | null>(null);
1442
+ const autoTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1443
+ const digilockerRedirectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1444
+ const isInitialMountRef = useRef(true);
1445
+ const isPollingStatusRef = useRef(false);
1446
+ const verificationSessionTokenRef = useRef<string | null>(null);
892
1447
 
893
1448
  // =============================================================================
894
1449
  // RESPONSIVE DESIGN & STYLING
@@ -1049,6 +1604,8 @@ export const RedactoNoticeConsent = ({
1049
1604
 
1050
1605
  // Clear age verification and guardian form state
1051
1606
  setShowAgeVerification(false);
1607
+ setIsMinorFlow(false);
1608
+ setSelfDeclaredAdult(false);
1052
1609
  setShowGuardianForm(false);
1053
1610
  setIsSubmittingGuardian(false);
1054
1611
  setGuardianFormData({
@@ -1057,6 +1614,47 @@ export const RedactoNoticeConsent = ({
1057
1614
  guardianRelationship: "",
1058
1615
  });
1059
1616
  setGuardianFormErrors({});
1617
+
1618
+ // Clear verification state
1619
+ setShowVerificationScreen(false);
1620
+ setVerificationReference(null);
1621
+ setIsInitiatingVerification(false);
1622
+ setVerificationError(null);
1623
+ setVerificationErrorCode(null);
1624
+ setCanRetryVerification(false);
1625
+ setIsVerificationComplete(false);
1626
+ setIsAutoTransitioning(false);
1627
+ setIsPollingStatus(false);
1628
+ isPollingStatusRef.current = false;
1629
+ verificationSessionTokenRef.current = null;
1630
+ if (popupCheckIntervalRef.current) {
1631
+ clearInterval(popupCheckIntervalRef.current);
1632
+ popupCheckIntervalRef.current = null;
1633
+ }
1634
+ if (statusPollIntervalRef.current) {
1635
+ clearTimeout(statusPollIntervalRef.current);
1636
+ statusPollIntervalRef.current = null;
1637
+ }
1638
+ // Clean up visibilitychange listener if polling was active
1639
+ pollingCleanupRef.current?.();
1640
+ pollingCleanupRef.current = null;
1641
+ verificationPopupRef.current?.close();
1642
+ verificationPopupRef.current = null;
1643
+ if (autoTransitionTimerRef.current) {
1644
+ clearTimeout(autoTransitionTimerRef.current);
1645
+ autoTransitionTimerRef.current = null;
1646
+ }
1647
+ if (digilockerRedirectTimeoutRef.current) {
1648
+ clearTimeout(digilockerRedirectTimeoutRef.current);
1649
+ digilockerRedirectTimeoutRef.current = null;
1650
+ }
1651
+
1652
+ // Clear persisted state only on token change, not initial mount.
1653
+ // The restore effect (below) needs to read persisted state on first mount.
1654
+ if (!isInitialMountRef.current) {
1655
+ clearConsentState();
1656
+ }
1657
+ isInitialMountRef.current = false;
1060
1658
  }, [accessToken]);
1061
1659
 
1062
1660
  /**
@@ -1361,7 +1959,14 @@ export const RedactoNoticeConsent = ({
1361
1959
  // Check if age verification is required - set this after all content is loaded
1362
1960
  // but before setIsLoading(false) to ensure proper render order
1363
1961
  if (consentContentData.detail.is_minor) {
1364
- setShowAgeVerification(true);
1962
+ setIsMinorFlow(true);
1963
+ // Only show age verification if not returning from a DigiLocker redirect
1964
+ // (persisted state means user is mid-verification flow — the state restoration
1965
+ // effect handles showing the correct screen)
1966
+ const persistedState = getConsentState();
1967
+ if (!persistedState?.sessionToken && !persistedState?.verificationReference) {
1968
+ setShowAgeVerification(true);
1969
+ }
1365
1970
  }
1366
1971
 
1367
1972
  // Set loading to false after all state updates are complete
@@ -1494,6 +2099,12 @@ export const RedactoNoticeConsent = ({
1494
2099
  const submitController = new AbortController();
1495
2100
 
1496
2101
  try {
2102
+ // Block submission if minor consent flow is active but verification reference is missing
2103
+ if (isMinorFlow && !verificationReference) {
2104
+ setErrorMessage("Guardian verification is required before consent can be submitted.");
2105
+ return;
2106
+ }
2107
+
1497
2108
  setIsSubmittingAccept(true);
1498
2109
  setIsSubmitting(true); // Keep for backward compatibility with acceptDisabled
1499
2110
  setErrorMessage(null);
@@ -1541,9 +2152,12 @@ export const RedactoNoticeConsent = ({
1541
2152
  };
1542
2153
  }),
1543
2154
  declined: false,
2155
+ language: toBcp47Code(selectedLanguage),
1544
2156
  meta_data: applicationId
1545
2157
  ? { specific_uuid: applicationId }
1546
2158
  : undefined,
2159
+ guardianVerificationReference: verificationReference || undefined,
2160
+ selfDeclaredAdult: selfDeclaredAdult || undefined,
1547
2161
  signal: submitController.signal,
1548
2162
  });
1549
2163
  }
@@ -1570,33 +2184,11 @@ export const RedactoNoticeConsent = ({
1570
2184
  const handleDecline = useCallback(async () => {
1571
2185
  try {
1572
2186
  setIsSubmittingDecline(true);
1573
- setIsSubmitting(true); // Keep for backward compatibility
1574
- // try {
1575
- // if (content) {
1576
- // await submitConsentEvent(
1577
- // accessToken,
1578
- // content.notice_uuid,
1579
- // content.purposes.map((purpose) => ({
1580
- // ...purpose,
1581
- // selected: false,
1582
- // data_elements: purpose.data_elements.map((element) => ({
1583
- // ...element,
1584
- // selected: element.required ? true : false,
1585
- // })),
1586
- // })),
1587
- // true
1588
- // );
1589
- // }
1590
- // } catch (error) {
1591
- // console.error("Error submitting consent:", error);
1592
- // onError?.(error as Error);
1593
- // } finally {
1594
- // setIsSubmitting(false);
1595
- // }
2187
+ setIsSubmitting(true);
1596
2188
  onDecline?.();
1597
2189
  } finally {
1598
2190
  setIsSubmittingDecline(false);
1599
- setIsSubmitting(false); // Keep for backward compatibility
2191
+ setIsSubmitting(false);
1600
2192
  }
1601
2193
  }, [onDecline]);
1602
2194
 
@@ -1646,21 +2238,140 @@ export const RedactoNoticeConsent = ({
1646
2238
  try {
1647
2239
  setIsSubmittingGuardian(true);
1648
2240
  setGuardianFormErrors({}); // Clear any previous errors
1649
-
1650
- await submitGuardianInfo({
2241
+ setVerificationError(null);
2242
+
2243
+ // For redirect mode: Don't send frontendCallbackUrl
2244
+ // DigiLocker should redirect to BACKEND callback URL (e.g., http://localhost:8000/consent/public/digilocker/callback)
2245
+ // Backend processes the callback and updates status in database
2246
+ // SDK will poll verify-status endpoint to detect completion
2247
+ //
2248
+ // For popup mode: Send frontendCallbackUrl so backend can redirect back to frontend
2249
+ const callbackUrl = digilockerMode === "popup"
2250
+ ? new URL(digilockerCallbackUrl, window.location.origin).href
2251
+ : undefined;
2252
+
2253
+ // Initiate guardian verification - combines guardian details + DigiLocker start
2254
+ const response = await initiateGuardianVerification({
1651
2255
  accessToken,
1652
2256
  baseUrl,
1653
2257
  guardianName: guardianFormData.guardianName,
1654
2258
  guardianContact: guardianFormData.guardianContact,
1655
2259
  guardianRelationship: guardianFormData.guardianRelationship,
2260
+ frontendCallbackUrl: callbackUrl, // Only for popup mode
1656
2261
  signal: guardianController.signal,
1657
2262
  });
1658
2263
 
1659
- // Success - proceed to consent form
2264
+ setIsSubmittingGuardian(false);
2265
+
2266
+ // ── Already verified: skip DigiLocker, brief success flash then consent form ──
2267
+ if (response.already_verified && response.verification_reference) {
2268
+ setVerificationReference(response.verification_reference);
2269
+ setShowGuardianForm(false);
2270
+ setShowVerificationScreen(true);
2271
+ setIsInitiatingVerification(false);
2272
+ setIsVerificationComplete(true);
2273
+ setIsAutoTransitioning(true);
2274
+
2275
+ // Auto-transition to consent form after brief success flash
2276
+ autoTransitionTimerRef.current = setTimeout(() => {
2277
+ setShowVerificationScreen(false);
2278
+ setIsVerificationComplete(false);
2279
+ setIsAutoTransitioning(false);
2280
+ autoTransitionTimerRef.current = null;
2281
+ }, 1000);
2282
+ return;
2283
+ }
2284
+
2285
+ // ── New verification: needs DigiLocker ──
2286
+ if (!response.session_token || !response.digilocker_redirect_url) {
2287
+ throw new Error("Invalid response from server: missing required fields");
2288
+ }
2289
+
2290
+ // Show verification screen before redirect
1660
2291
  setShowGuardianForm(false);
2292
+ setShowVerificationScreen(true);
2293
+ setIsInitiatingVerification(true);
2294
+
2295
+ if (digilockerMode === "redirect") {
2296
+ // Save state BEFORE redirect — restored when user returns from DigiLocker callback
2297
+ // NOTE: Guardian PII (name, contact, relationship) intentionally excluded from
2298
+ // persisted state to avoid storing sensitive data in sessionStorage.
2299
+ saveConsentState({
2300
+ flowState: "digilocker-pending",
2301
+ sessionToken: response.session_token,
2302
+ guardianInfoUuid: response.guardian_info_uuid,
2303
+ selectedPurposes,
2304
+ selectedDataElements,
2305
+ timestamp: new Date().toISOString(),
2306
+ originalUrl: window.location.href,
2307
+ noticeId,
2308
+ baseUrl,
2309
+ });
2310
+
2311
+ // Open DigiLocker in a new tab — keeps original page open for polling
2312
+ // Flow: DigiLocker → backend callback → backend updates DB → this tab polls → detects completion
2313
+ // NOTE: Do NOT pass "noopener" in the features string (3rd param) — it causes
2314
+ // window.open to return null per spec, even when the tab opens successfully.
2315
+ // Instead, manually null out opener after opening.
2316
+ try {
2317
+ const openedWindow = window.open(
2318
+ response.digilocker_redirect_url,
2319
+ "_blank"
2320
+ );
2321
+
2322
+ if (!openedWindow) {
2323
+ // Popup was blocked — show error instead of same-tab redirect
2324
+ // (same-tab redirect would lose the current page with no frontend route back)
2325
+ console.warn("Popup blocked — cannot open DigiLocker in new tab");
2326
+ setIsInitiatingVerification(false);
2327
+ setVerificationError(
2328
+ "Unable to open DigiLocker in a new tab. Please allow popups for this site and try again."
2329
+ );
2330
+ return;
2331
+ }
2332
+
2333
+ // Prevent the opened tab from accessing window.opener (security)
2334
+ try { openedWindow.opener = null; } catch { /* cross-origin — safe to ignore */ }
2335
+ } catch (error) {
2336
+ console.error("Failed to open DigiLocker in new tab:", error);
2337
+ setIsInitiatingVerification(false);
2338
+ setVerificationError(
2339
+ "Unable to open DigiLocker. Please allow popups for this site and try again."
2340
+ );
2341
+ return;
2342
+ }
2343
+
2344
+ setIsInitiatingVerification(false);
2345
+ setIsPollingStatus(true);
2346
+ isPollingStatusRef.current = true;
2347
+ startStatusPolling(response.session_token);
2348
+ } else {
2349
+ // Popup mode - open DigiLocker in new window
2350
+ setIsInitiatingVerification(true);
2351
+
2352
+ const popup = window.open(
2353
+ response.digilocker_redirect_url,
2354
+ "digilocker_verification",
2355
+ "width=600,height=700,scrollbars=yes,resizable=yes"
2356
+ );
2357
+
2358
+ if (popup) {
2359
+ verificationPopupRef.current = popup;
2360
+ startPopupMonitoring(popup);
2361
+ startStatusPolling(response.session_token);
2362
+ // Keep isInitiatingVerification true while polling
2363
+ } else {
2364
+ // Popup blocked - fall back to redirect
2365
+ setIsInitiatingVerification(false);
2366
+ setVerificationError(
2367
+ "Popup blocked. Please allow popups for this site or use redirect mode."
2368
+ );
2369
+ }
2370
+ }
1661
2371
  } catch (error) {
1662
2372
  // Handle API errors
1663
2373
  const err = error as Error & { status?: number };
2374
+ setIsInitiatingVerification(false);
1664
2375
 
1665
2376
  if (err.status === 422) {
1666
2377
  // Validation error from server - try to extract field-specific errors
@@ -1673,23 +2384,30 @@ export const RedactoNoticeConsent = ({
1673
2384
  } else {
1674
2385
  setGuardianFormErrors({ general: err.message });
1675
2386
  }
2387
+ // Go back to guardian form to show errors
2388
+ setShowVerificationScreen(false);
2389
+ setShowGuardianForm(true);
1676
2390
  } else {
1677
2391
  // Other errors - show general error
1678
2392
  setGuardianFormErrors({
1679
2393
  general:
1680
2394
  err.message ||
1681
- "Failed to submit guardian information. Please try again.",
2395
+ "Failed to initiate verification. Please try again.",
1682
2396
  });
2397
+ setShowVerificationScreen(false);
2398
+ setShowGuardianForm(true);
1683
2399
  onError?.(err);
1684
2400
  }
1685
2401
  } finally {
1686
2402
  setIsSubmittingGuardian(false);
1687
2403
  }
1688
- }, [accessToken, baseUrl, guardianFormData, onError]);
2404
+ }, [accessToken, baseUrl, guardianFormData, digilockerMode, digilockerCallbackUrl, selectedPurposes, selectedDataElements, noticeId, onError]);
1689
2405
 
1690
2406
  // Age verification handlers
1691
2407
  const handleAgeVerificationYes = useCallback(() => {
1692
2408
  setShowAgeVerification(false);
2409
+ setIsMinorFlow(false);
2410
+ setSelfDeclaredAdult(true);
1693
2411
  // User is 18+, proceed directly to consent
1694
2412
  }, []);
1695
2413
 
@@ -1699,6 +2417,299 @@ export const RedactoNoticeConsent = ({
1699
2417
  // User is under 18, show guardian form
1700
2418
  }, []);
1701
2419
 
2420
+ const handleVerificationContinue = useCallback(() => {
2421
+ // Transition from verification success screen to consent form
2422
+ setShowVerificationScreen(false);
2423
+ setIsVerificationComplete(false);
2424
+ }, []);
2425
+
2426
+ // Guardian verification handlers
2427
+ const handlePopupClosed = useCallback(async () => {
2428
+ // Stop the popup monitor
2429
+ if (popupCheckIntervalRef.current) {
2430
+ clearInterval(popupCheckIntervalRef.current);
2431
+ popupCheckIntervalRef.current = null;
2432
+ }
2433
+ // Stop status polling timer
2434
+ if (statusPollIntervalRef.current) {
2435
+ clearTimeout(statusPollIntervalRef.current);
2436
+ statusPollIntervalRef.current = null;
2437
+ }
2438
+ // Clean up visibilitychange listener
2439
+ pollingCleanupRef.current?.();
2440
+ pollingCleanupRef.current = null;
2441
+ // Null out popup ref
2442
+ verificationPopupRef.current = null;
2443
+
2444
+ // Use ref to avoid stale closure — startPopupMonitoring installs interval
2445
+ // before startStatusPolling flips isPollingStatus state
2446
+ if (!isPollingStatusRef.current) return;
2447
+
2448
+ // Perform one final status check — the user may have completed verification
2449
+ // right before closing the popup
2450
+ const sessionToken = verificationSessionTokenRef.current;
2451
+ if (sessionToken) {
2452
+ try {
2453
+ const response = await verifyGuardianStatus({
2454
+ accessToken,
2455
+ baseUrl,
2456
+ sessionToken,
2457
+ });
2458
+
2459
+ if (response.status === "verified") {
2460
+ const ref = response.verification_reference || response.guardian_details?.verification_reference;
2461
+ if (ref) {
2462
+ setVerificationReference(ref);
2463
+ setIsVerificationComplete(true);
2464
+ setIsPollingStatus(false);
2465
+ isPollingStatusRef.current = false;
2466
+ setIsInitiatingVerification(false);
2467
+ clearConsentState();
2468
+ return;
2469
+ }
2470
+ }
2471
+ } catch {
2472
+ // Final check failed — fall through to show error
2473
+ }
2474
+ }
2475
+
2476
+ // Verification not complete — mark as failure
2477
+ setVerificationError(
2478
+ "Verification window was closed. Please try again."
2479
+ );
2480
+ setIsPollingStatus(false);
2481
+ isPollingStatusRef.current = false;
2482
+ setIsInitiatingVerification(false);
2483
+ }, [accessToken, baseUrl]);
2484
+
2485
+ const startPopupMonitoring = useCallback((popup: Window) => {
2486
+ // Check every 500ms if popup is closed
2487
+ popupCheckIntervalRef.current = window.setInterval(() => {
2488
+ if (popup.closed) {
2489
+ handlePopupClosed();
2490
+ }
2491
+ }, 500);
2492
+ }, [handlePopupClosed]);
2493
+
2494
+ /** Map error_code to user-friendly message */
2495
+ const getVerificationErrorMessage = useCallback(
2496
+ (errorCode: string | null, fallbackError: string | null): string => {
2497
+ switch (errorCode) {
2498
+ case "GUARDIAN_UNDER_18":
2499
+ return "The guardian must be 18 years or older. Please provide details of a different guardian.";
2500
+ case "TOKEN_FAILED":
2501
+ return "DigiLocker verification could not be completed. Please try again.";
2502
+ case "DIGILOCKER_AUTH_FAILED":
2503
+ return "DigiLocker authorization was cancelled or failed. Please try again.";
2504
+ case "NO_AUTH_CODE":
2505
+ return "DigiLocker did not return an authorization code. Please try again.";
2506
+ case "SESSION_EXPIRED":
2507
+ return "Verification session has expired. Please try again.";
2508
+ default:
2509
+ return fallbackError || "Verification failed. Please try again.";
2510
+ }
2511
+ },
2512
+ []
2513
+ );
2514
+
2515
+ /**
2516
+ * Poll verification status every 3s, hard stop at 2 minutes.
2517
+ * Pauses when tab is hidden (user is on DigiLocker tab), resumes on return.
2518
+ */
2519
+ const startStatusPolling = useCallback(
2520
+ (token: string) => {
2521
+ verificationSessionTokenRef.current = token;
2522
+ setIsPollingStatus(true);
2523
+ isPollingStatusRef.current = true;
2524
+ setVerificationError(null);
2525
+ setVerificationErrorCode(null);
2526
+ setCanRetryVerification(false);
2527
+
2528
+ let attempts = 0;
2529
+ let consecutiveErrors = 0;
2530
+ let paused = false;
2531
+ const MAX_ATTEMPTS = 40; // 40 × 3s = 2 minutes
2532
+ const MAX_CONSECUTIVE_ERRORS = 3;
2533
+ const POLL_INTERVAL = 3000;
2534
+
2535
+ const stopPolling = () => {
2536
+ if (statusPollIntervalRef.current) {
2537
+ clearTimeout(statusPollIntervalRef.current);
2538
+ statusPollIntervalRef.current = null;
2539
+ }
2540
+ if (popupCheckIntervalRef.current) {
2541
+ clearInterval(popupCheckIntervalRef.current);
2542
+ popupCheckIntervalRef.current = null;
2543
+ }
2544
+ verificationPopupRef.current?.close();
2545
+ verificationPopupRef.current = null;
2546
+ document.removeEventListener("visibilitychange", onVisibilityChange);
2547
+ pollingCleanupRef.current = null;
2548
+ setIsPollingStatus(false);
2549
+ isPollingStatusRef.current = false;
2550
+ setIsInitiatingVerification(false);
2551
+ };
2552
+
2553
+ const setError = (message: string, errorCode: string | null, canRetry: boolean) => {
2554
+ stopPolling();
2555
+ setVerificationError(message);
2556
+ setVerificationErrorCode(errorCode);
2557
+ setCanRetryVerification(canRetry);
2558
+ };
2559
+
2560
+ const poll = async () => {
2561
+ if (paused) return;
2562
+
2563
+ attempts++;
2564
+
2565
+ if (attempts > MAX_ATTEMPTS) {
2566
+ setError(
2567
+ "Verification is taking longer than expected. Please try again.",
2568
+ "SESSION_EXPIRED",
2569
+ true
2570
+ );
2571
+ return;
2572
+ }
2573
+
2574
+ try {
2575
+ const response = await verifyGuardianStatus({
2576
+ accessToken,
2577
+ baseUrl,
2578
+ sessionToken: token,
2579
+ });
2580
+
2581
+ // Reset consecutive errors on any successful API call
2582
+ consecutiveErrors = 0;
2583
+
2584
+ if (response.status === "verified") {
2585
+ stopPolling();
2586
+ const ref = response.verification_reference || response.guardian_details?.verification_reference;
2587
+ if (!ref) {
2588
+ setError(
2589
+ "Verification completed but no reference received. Please try again.",
2590
+ null,
2591
+ true
2592
+ );
2593
+ return;
2594
+ }
2595
+ setVerificationReference(ref);
2596
+ setIsVerificationComplete(true);
2597
+ clearConsentState();
2598
+ return;
2599
+ }
2600
+
2601
+ if (response.status === "failed" || response.status === "expired") {
2602
+ setError(
2603
+ getVerificationErrorMessage(response.error_code, response.error),
2604
+ response.error_code,
2605
+ response.can_retry
2606
+ );
2607
+ return;
2608
+ }
2609
+
2610
+ // pending / in_progress — poll again
2611
+ if (!paused) {
2612
+ statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
2613
+ }
2614
+ } catch (error) {
2615
+ const err = error as Error & { status?: number };
2616
+
2617
+ if (err.status === 404 || err.status === 410) {
2618
+ setError(
2619
+ "Verification session not found or expired. Please try again.",
2620
+ "SESSION_EXPIRED",
2621
+ true
2622
+ );
2623
+ return;
2624
+ }
2625
+
2626
+ // Transient error — keep polling, fail after consecutive threshold
2627
+ consecutiveErrors++;
2628
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2629
+ setError(
2630
+ err.message || "Unable to check verification status. Please try again.",
2631
+ null,
2632
+ true
2633
+ );
2634
+ return;
2635
+ }
2636
+
2637
+ statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
2638
+ }
2639
+ };
2640
+
2641
+ // Pause when tab hidden, resume immediately when visible
2642
+ const onVisibilityChange = () => {
2643
+ if (document.hidden) {
2644
+ paused = true;
2645
+ if (statusPollIntervalRef.current) {
2646
+ clearTimeout(statusPollIntervalRef.current);
2647
+ statusPollIntervalRef.current = null;
2648
+ }
2649
+ } else {
2650
+ paused = false;
2651
+ poll();
2652
+ }
2653
+ };
2654
+
2655
+ document.addEventListener("visibilitychange", onVisibilityChange);
2656
+ // Store cleanup so unmount/reset can remove the listener
2657
+ pollingCleanupRef.current = () => {
2658
+ document.removeEventListener("visibilitychange", onVisibilityChange);
2659
+ };
2660
+
2661
+ // First poll after 3s
2662
+ statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
2663
+ },
2664
+ [accessToken, baseUrl, getVerificationErrorMessage]
2665
+ );
2666
+
2667
+
2668
+ const handleBackToGuardianForm = useCallback((clearName = false) => {
2669
+ // Close any open popup
2670
+ verificationPopupRef.current?.close();
2671
+ verificationPopupRef.current = null;
2672
+
2673
+ // Clear all timers
2674
+ if (popupCheckIntervalRef.current) {
2675
+ clearInterval(popupCheckIntervalRef.current);
2676
+ popupCheckIntervalRef.current = null;
2677
+ }
2678
+ if (statusPollIntervalRef.current) {
2679
+ clearTimeout(statusPollIntervalRef.current);
2680
+ statusPollIntervalRef.current = null;
2681
+ }
2682
+ if (autoTransitionTimerRef.current) {
2683
+ clearTimeout(autoTransitionTimerRef.current);
2684
+ autoTransitionTimerRef.current = null;
2685
+ }
2686
+ if (digilockerRedirectTimeoutRef.current) {
2687
+ clearTimeout(digilockerRedirectTimeoutRef.current);
2688
+ digilockerRedirectTimeoutRef.current = null;
2689
+ }
2690
+ // Clean up visibilitychange listener
2691
+ pollingCleanupRef.current?.();
2692
+ pollingCleanupRef.current = null;
2693
+
2694
+ // Reset verification state
2695
+ setShowVerificationScreen(false);
2696
+ setShowGuardianForm(true);
2697
+ setVerificationError(null);
2698
+ setVerificationErrorCode(null);
2699
+ setCanRetryVerification(false);
2700
+ setIsInitiatingVerification(false);
2701
+ setIsPollingStatus(false);
2702
+ isPollingStatusRef.current = false;
2703
+ setIsVerificationComplete(false);
2704
+
2705
+ // For GUARDIAN_UNDER_18: clear name so user enters a different guardian
2706
+ if (clearName) {
2707
+ setGuardianFormData((prev) => ({ ...prev, guardianName: "" }));
2708
+ }
2709
+
2710
+ clearConsentState();
2711
+ }, []);
2712
+
1702
2713
  const availableLanguages = useMemo(() => {
1703
2714
  if (!content) return [language];
1704
2715
 
@@ -2328,9 +3339,77 @@ export const RedactoNoticeConsent = ({
2328
3339
  }
2329
3340
  // Stop and cleanup audio
2330
3341
  stopAudio();
3342
+ // Cleanup verification intervals, popup, and visibilitychange listener
3343
+ if (popupCheckIntervalRef.current) {
3344
+ clearInterval(popupCheckIntervalRef.current);
3345
+ }
3346
+ if (statusPollIntervalRef.current) {
3347
+ clearTimeout(statusPollIntervalRef.current);
3348
+ }
3349
+ pollingCleanupRef.current?.();
3350
+ pollingCleanupRef.current = null;
3351
+ verificationPopupRef.current?.close();
3352
+ if (autoTransitionTimerRef.current) {
3353
+ clearTimeout(autoTransitionTimerRef.current);
3354
+ autoTransitionTimerRef.current = null;
3355
+ }
3356
+ if (digilockerRedirectTimeoutRef.current) {
3357
+ clearTimeout(digilockerRedirectTimeoutRef.current);
3358
+ digilockerRedirectTimeoutRef.current = null;
3359
+ }
2331
3360
  };
2332
3361
  }, [stopAudio]);
2333
3362
 
3363
+ // Restore state from redirect flow (when user returns from DigiLocker)
3364
+ useEffect(() => {
3365
+ const persistedState = getConsentState();
3366
+ if (!persistedState) return;
3367
+
3368
+ // Validate persisted state belongs to this notice and hasn't expired
3369
+ if (persistedState.noticeId && persistedState.noticeId !== noticeId) {
3370
+ clearConsentState();
3371
+ return;
3372
+ }
3373
+ const REDIRECT_STATE_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
3374
+ if (persistedState.timestamp) {
3375
+ const stateAge = Date.now() - new Date(persistedState.timestamp).getTime();
3376
+ if (stateAge > REDIRECT_STATE_MAX_AGE_MS) {
3377
+ clearConsentState();
3378
+ return;
3379
+ }
3380
+ }
3381
+
3382
+ // Check if this is a redirect return
3383
+ if (persistedState.flowState === "digilocker-pending" && persistedState.sessionToken) {
3384
+ // Resume verification status polling
3385
+ setShowVerificationScreen(true);
3386
+ setShowGuardianForm(false);
3387
+ setShowAgeVerification(false);
3388
+
3389
+ // Start polling for status
3390
+ startStatusPolling(persistedState.sessionToken);
3391
+ } else if (persistedState.verificationResult === "verified" && persistedState.verificationReference) {
3392
+ // Verification completed during redirect, show success screen with Continue button
3393
+ setVerificationReference(persistedState.verificationReference);
3394
+ setShowVerificationScreen(true);
3395
+ setIsVerificationComplete(true);
3396
+ setShowGuardianForm(false);
3397
+ setShowAgeVerification(false);
3398
+ clearConsentState();
3399
+ } else if (persistedState.verificationResult === "failed" || persistedState.verificationResult === "expired") {
3400
+ // Verification failed during redirect, show error
3401
+ setShowVerificationScreen(true);
3402
+ setShowGuardianForm(false);
3403
+ setShowAgeVerification(false);
3404
+ setVerificationError(
3405
+ persistedState.verificationResult === "expired"
3406
+ ? "Verification session has expired. Please try again."
3407
+ : "Verification failed. Please try again."
3408
+ );
3409
+ clearConsentState();
3410
+ }
3411
+ }, [startStatusPolling, noticeId]);
3412
+
2334
3413
  // =============================================================================
2335
3414
  // RENDER
2336
3415
  // =============================================================================
@@ -2511,6 +3590,28 @@ export const RedactoNoticeConsent = ({
2511
3590
  logoUrl={content?.logo_url || logo}
2512
3591
  />
2513
3592
  </div>
3593
+ ) : showVerificationScreen ? (
3594
+ <div style={{ ...styles.content, ...responsiveStyles.content }}>
3595
+ <VerificationScreen
3596
+ onBack={handleBackToGuardianForm}
3597
+ onContinue={handleVerificationContinue}
3598
+ isInitiating={isInitiatingVerification}
3599
+ isPolling={isPollingStatus}
3600
+ isVerificationComplete={isVerificationComplete}
3601
+ isAutoTransitioning={isAutoTransitioning}
3602
+ error={verificationError}
3603
+ errorCode={verificationErrorCode}
3604
+ canRetry={canRetryVerification}
3605
+ styles={styles}
3606
+ responsiveStyles={responsiveStyles}
3607
+ settings={settings}
3608
+ componentHeadingStyle={componentHeadingStyle}
3609
+ componentTextStyle={componentTextStyle}
3610
+ acceptButtonStyle={acceptButtonStyle}
3611
+ declineButtonStyle={declineButtonStyle}
3612
+ logoUrl={content?.logo_url || logo}
3613
+ />
3614
+ </div>
2514
3615
  ) : (
2515
3616
  <div style={{ ...styles.content, ...responsiveStyles.content }}>
2516
3617
  {errorMessage && (