@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +176 -0
- package/dist/index.d.mts +51 -4
- package/dist/index.d.ts +51 -4
- package/dist/index.js +1912 -345
- package/dist/index.mjs +1911 -345
- package/dist/privacy-center.d.mts +566 -0
- package/dist/privacy-center.d.ts +566 -0
- package/dist/privacy-center.js +17174 -0
- package/dist/privacy-center.mjs +17136 -0
- package/package.json +47 -3
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +201 -118
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1194 -93
- package/src/RedactoNoticeConsent/api/index.ts +337 -246
- package/src/RedactoNoticeConsent/api/types.ts +140 -41
- package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +602 -0
- package/src/RedactoNoticeConsent/components/index.ts +7 -0
- package/src/RedactoNoticeConsent/injectStyles.ts +11 -0
- package/src/RedactoNoticeConsent/language-codes.ts +61 -0
- package/src/RedactoNoticeConsent/services/storage.ts +122 -0
- package/src/RedactoNoticeConsent/types.ts +2 -0
- package/src/RedactoNoticeConsentInline/api/index.ts +26 -64
- package/src/RedactoNoticeConsentInline/api/types.ts +0 -25
- package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +19 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +17 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +79 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +27 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +142 -0
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +51 -0
- package/src/RedactoPrivacyCenter/api/actions.ts +444 -0
- package/src/RedactoPrivacyCenter/api/client.ts +413 -0
- package/src/RedactoPrivacyCenter/api/config.ts +33 -0
- package/src/RedactoPrivacyCenter/api/fetcher.ts +181 -0
- package/src/RedactoPrivacyCenter/api/upload.ts +93 -0
- package/src/RedactoPrivacyCenter/assets/character.svg +31 -0
- package/src/RedactoPrivacyCenter/assets/fileAttach.svg +9 -0
- package/src/RedactoPrivacyCenter/assets/greenTick.svg +4 -0
- package/src/RedactoPrivacyCenter/assets/mail.svg +3 -0
- package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +9 -0
- package/src/RedactoPrivacyCenter/assets/sentImg.svg +9 -0
- package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +9 -0
- package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +272 -0
- package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +468 -0
- package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +433 -0
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +726 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +239 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +7 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +155 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +189 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +118 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +66 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +305 -0
- package/src/RedactoPrivacyCenter/components/Form.tsx +1455 -0
- package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +176 -0
- package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +330 -0
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +329 -0
- package/src/RedactoPrivacyCenter/components/Pagination.tsx +140 -0
- package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +302 -0
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +218 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +475 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +412 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +42 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +1 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +40 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +38 -0
- package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +85 -0
- package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +141 -0
- package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +179 -0
- package/src/RedactoPrivacyCenter/components/constant.ts +20 -0
- package/src/RedactoPrivacyCenter/components/types.ts +68 -0
- package/src/RedactoPrivacyCenter/components/utils.tsx +10 -0
- package/src/RedactoPrivacyCenter/config/routes.ts +9 -0
- package/src/RedactoPrivacyCenter/config/table.ts +25 -0
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +123 -0
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +44 -0
- package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +11 -0
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +64 -0
- package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +30 -0
- package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +60 -0
- package/src/RedactoPrivacyCenter/context/theme.tsx +127 -0
- package/src/RedactoPrivacyCenter/lib/api.ts +468 -0
- package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +63 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +116 -0
- package/src/RedactoPrivacyCenter/lib/i18n.ts +81 -0
- package/src/RedactoPrivacyCenter/lib/locales.ts +105 -0
- package/src/RedactoPrivacyCenter/lib/queries.ts +30 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +346 -0
- package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +37 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +625 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/en/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/or/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/te/translation.json +188 -0
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +188 -0
- package/src/RedactoPrivacyCenter/styles/base.css +117 -0
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1000 -0
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +819 -0
- package/src/RedactoPrivacyCenter/styles/responsive.ts +138 -0
- package/src/RedactoPrivacyCenter/styles/variables.css +133 -0
- package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +365 -0
- package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +479 -0
- package/src/RedactoPrivacyCenter/table/index.ts +2 -0
- package/src/RedactoPrivacyCenter/types/images.d.ts +29 -0
- package/src/RedactoPrivacyCenter/ui/Footer.tsx +73 -0
- package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +54 -0
- package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +87 -0
- package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +47 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +69 -0
- package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +48 -0
- package/src/RedactoPrivacyCenter/ui/PCButton.tsx +77 -0
- package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +55 -0
- package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +97 -0
- package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +35 -0
- package/src/RedactoPrivacyCenter/ui/PCInput.tsx +128 -0
- package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +30 -0
- package/src/RedactoPrivacyCenter/ui/PCModal.tsx +104 -0
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +175 -0
- package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +381 -0
- package/src/RedactoPrivacyCenter/ui/PCTable.tsx +137 -0
- package/src/RedactoPrivacyCenter/ui/PCText.tsx +39 -0
- package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +43 -0
- package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +37 -0
- package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +119 -0
- package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +25 -0
- package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +55 -0
- package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +70 -0
- package/src/RedactoPrivacyCenter/ui/index.ts +18 -0
- package/src/RedactoPrivacyCenter/utils/cn.ts +28 -0
- package/src/RedactoPrivacyCenter/utils/date.ts +48 -0
- package/src/RedactoPrivacyCenter/utils/status.ts +111 -0
- package/src/RedactoPrivacyCenter/utils/toast.ts +130 -0
- package/src/index.ts +10 -0
- package/src/privacy-center.ts +107 -0
- package/tsconfig.json +2 -0
- package/tsup.config.ts +16 -1
- 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
|
-
|
|
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 ${
|
|
397
|
-
borderRadius:
|
|
487
|
+
border: `1px solid ${borderColor}`,
|
|
488
|
+
borderRadius: radius,
|
|
398
489
|
fontSize: "13px",
|
|
399
|
-
fontFamily:
|
|
400
|
-
color:
|
|
401
|
-
backgroundColor:
|
|
402
|
-
marginBottom: "
|
|
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: "
|
|
500
|
+
fontSize: "11px",
|
|
409
501
|
marginTop: "4px",
|
|
410
|
-
marginBottom: "
|
|
502
|
+
marginBottom: "0px",
|
|
411
503
|
};
|
|
412
504
|
|
|
413
505
|
const labelStyle = {
|
|
414
506
|
...componentTextStyle,
|
|
415
507
|
fontSize: "13px",
|
|
416
|
-
fontWeight: "500",
|
|
417
|
-
marginBottom: "
|
|
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="
|
|
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
|
|
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: "
|
|
454
|
-
lineHeight: "1.
|
|
570
|
+
fontSize: "13px",
|
|
571
|
+
lineHeight: "1.5",
|
|
572
|
+
marginBottom: "12px",
|
|
455
573
|
}}
|
|
456
574
|
>
|
|
457
|
-
|
|
458
|
-
|
|
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: "
|
|
466
|
-
marginTop: "16px",
|
|
604
|
+
gap: "14px",
|
|
467
605
|
}}
|
|
468
606
|
>
|
|
469
607
|
<div>
|
|
470
|
-
<label
|
|
471
|
-
|
|
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="
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
<
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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:
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
? "
|
|
585
|
-
: "
|
|
826
|
+
? "Verifying guardian identity"
|
|
827
|
+
: "Proceed to guardian verification"
|
|
586
828
|
}
|
|
587
829
|
>
|
|
588
|
-
{isSubmitting ? "
|
|
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
|
|
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);
|
|
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
|
-
|
|
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);
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 && (
|