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