@redacto.io/consent-sdk-react 10.0.0-beta.9 → 10.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.
- package/NOTICE.md +17 -0
- package/README.md +141 -3
- package/dist/chunk-FEGTW73Q.mjs +182 -0
- package/dist/fonts.d.mts +35 -0
- package/dist/fonts.d.ts +35 -0
- package/dist/fonts.js +183 -0
- package/dist/fonts.mjs +10 -0
- package/dist/index.d.mts +12 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +724 -365
- package/dist/index.mjs +346 -201
- package/dist/montserrat.generated-NBTECYRW.mjs +22 -0
- package/dist/privacy-center.d.mts +0 -1
- package/dist/privacy-center.d.ts +0 -1
- package/dist/privacy-center.js +2 -66
- package/dist/privacy-center.mjs +2 -66
- package/dist/types-6Aj7faxL.d.mts +57 -0
- package/dist/types-6Aj7faxL.d.ts +57 -0
- package/package.json +10 -1
- package/.turbo/turbo-build.log +0 -22
- package/.turbo/turbo-test.log +0 -203
- package/.turbo/turbo-verify-pack.log +0 -6
- package/CHANGELOG.md +0 -353
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +0 -934
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +0 -2160
- package/src/RedactoNoticeAssisted/api/index.ts +0 -275
- package/src/RedactoNoticeAssisted/api/types.ts +0 -182
- package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeAssisted/constants.ts +0 -51
- package/src/RedactoNoticeAssisted/i18n.ts +0 -1408
- package/src/RedactoNoticeAssisted/index.ts +0 -1
- package/src/RedactoNoticeAssisted/injectStyles.ts +0 -251
- package/src/RedactoNoticeAssisted/styles.ts +0 -730
- package/src/RedactoNoticeAssisted/types.ts +0 -26
- package/src/RedactoNoticeAssisted/useMediaQuery.ts +0 -36
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +0 -1152
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +0 -4308
- package/src/RedactoNoticeConsent/api/index.ts +0 -542
- package/src/RedactoNoticeConsent/api/sandbox.ts +0 -150
- package/src/RedactoNoticeConsent/api/types.ts +0 -515
- package/src/RedactoNoticeConsent/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +0 -602
- package/src/RedactoNoticeConsent/components/index.ts +0 -7
- package/src/RedactoNoticeConsent/constants.ts +0 -20
- package/src/RedactoNoticeConsent/index.ts +0 -1
- package/src/RedactoNoticeConsent/injectStyles.ts +0 -151
- package/src/RedactoNoticeConsent/language-codes.ts +0 -95
- package/src/RedactoNoticeConsent/services/storage.ts +0 -122
- package/src/RedactoNoticeConsent/styles.ts +0 -409
- package/src/RedactoNoticeConsent/types.ts +0 -80
- package/src/RedactoNoticeConsent/useMediaQuery.ts +0 -36
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.test.tsx +0 -369
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -687
- package/src/RedactoNoticeConsentInline/api/index.ts +0 -106
- package/src/RedactoNoticeConsentInline/api/types.ts +0 -183
- package/src/RedactoNoticeConsentInline/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeConsentInline/index.ts +0 -1
- package/src/RedactoNoticeConsentInline/injectStyles.ts +0 -40
- package/src/RedactoNoticeConsentInline/styles.ts +0 -397
- package/src/RedactoNoticeConsentInline/types.ts +0 -51
- package/src/RedactoNoticeConsentInline/useMediaQuery.ts +0 -36
- package/src/RedactoPrivacyCenter/NotificationsPage.tsx +0 -182
- package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +0 -19
- package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +0 -156
- package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +0 -17
- package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +0 -79
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +0 -36
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +0 -177
- package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +0 -26
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +0 -93
- package/src/RedactoPrivacyCenter/api/actions.ts +0 -695
- package/src/RedactoPrivacyCenter/api/client.ts +0 -1003
- package/src/RedactoPrivacyCenter/api/config.ts +0 -33
- package/src/RedactoPrivacyCenter/api/fetcher.ts +0 -275
- package/src/RedactoPrivacyCenter/api/orgBranding.ts +0 -16
- package/src/RedactoPrivacyCenter/api/upload.ts +0 -93
- package/src/RedactoPrivacyCenter/assets/character.svg +0 -31
- package/src/RedactoPrivacyCenter/assets/fileAttach.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/greenTick.svg +0 -4
- package/src/RedactoPrivacyCenter/assets/mail.svg +0 -3
- package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/sentImg.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +0 -9
- package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +0 -252
- package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +0 -239
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +0 -268
- package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +0 -218
- package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +0 -205
- package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +0 -107
- package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +0 -100
- package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +0 -339
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +0 -245
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +0 -28
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +0 -40
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +0 -62
- package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +0 -36
- package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +0 -38
- package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +0 -31
- package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +0 -79
- package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +0 -25
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +0 -96
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +0 -97
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +0 -156
- package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +0 -171
- package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +0 -23
- package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +0 -116
- package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +0 -43
- package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +0 -71
- package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +0 -469
- package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +0 -426
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +0 -1206
- package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +0 -218
- package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +0 -138
- package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +0 -7
- package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +0 -179
- package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +0 -318
- package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +0 -333
- package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +0 -102
- package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +0 -159
- package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +0 -189
- package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +0 -118
- package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +0 -66
- package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +0 -222
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +0 -331
- package/src/RedactoPrivacyCenter/components/Form.tsx +0 -1821
- package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +0 -174
- package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +0 -337
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +0 -334
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +0 -173
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +0 -18
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +0 -54
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +0 -57
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +0 -19
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +0 -92
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +0 -20
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +0 -58
- package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +0 -10
- package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +0 -112
- package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +0 -27
- package/src/RedactoPrivacyCenter/components/Pagination.tsx +0 -140
- package/src/RedactoPrivacyCenter/components/PrivacyCenterEmptyState.tsx +0 -76
- package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +0 -97
- package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +0 -454
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +0 -270
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +0 -95
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +0 -195
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +0 -25
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -21
- package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +0 -159
- package/src/RedactoPrivacyCenter/components/constant.ts +0 -52
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +0 -155
- package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +0 -203
- package/src/RedactoPrivacyCenter/components/types.ts +0 -123
- package/src/RedactoPrivacyCenter/components/utils.tsx +0 -10
- package/src/RedactoPrivacyCenter/config/routes.ts +0 -9
- package/src/RedactoPrivacyCenter/config/table.ts +0 -25
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +0 -252
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +0 -47
- package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +0 -52
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +0 -92
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +0 -181
- package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +0 -11
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +0 -96
- package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +0 -30
- package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +0 -80
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +0 -15
- package/src/RedactoPrivacyCenter/context/types.ts +0 -57
- package/src/RedactoPrivacyCenter/lib/api.ts +0 -759
- package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +0 -67
- package/src/RedactoPrivacyCenter/lib/constants.ts +0 -283
- package/src/RedactoPrivacyCenter/lib/i18n.ts +0 -81
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -102
- package/src/RedactoPrivacyCenter/lib/queries.ts +0 -141
- package/src/RedactoPrivacyCenter/lib/sandbox.ts +0 -104
- package/src/RedactoPrivacyCenter/lib/types.ts +0 -896
- package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +0 -37
- package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +0 -127
- package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +0 -85
- package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +0 -109
- package/src/RedactoPrivacyCenter/lib/utils.ts +0 -798
- package/src/RedactoPrivacyCenter/locales/as/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/en/translation.json +0 -357
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +0 -345
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/or/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/te/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +0 -342
- package/src/RedactoPrivacyCenter/styles/applyBrandingTheme.ts +0 -66
- package/src/RedactoPrivacyCenter/styles/base.css +0 -117
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +0 -3108
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +0 -831
- package/src/RedactoPrivacyCenter/styles/responsive.ts +0 -138
- package/src/RedactoPrivacyCenter/styles/variables.css +0 -96
- package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +0 -387
- package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +0 -468
- package/src/RedactoPrivacyCenter/table/index.ts +0 -2
- package/src/RedactoPrivacyCenter/types/images.d.ts +0 -29
- package/src/RedactoPrivacyCenter/ui/Footer.tsx +0 -69
- package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +0 -54
- package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +0 -93
- package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +0 -47
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +0 -83
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +0 -93
- package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +0 -48
- package/src/RedactoPrivacyCenter/ui/PCButton.tsx +0 -77
- package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +0 -55
- package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +0 -107
- package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +0 -35
- package/src/RedactoPrivacyCenter/ui/PCInput.tsx +0 -132
- package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +0 -31
- package/src/RedactoPrivacyCenter/ui/PCModal.tsx +0 -106
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +0 -206
- package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +0 -433
- package/src/RedactoPrivacyCenter/ui/PCTable.tsx +0 -149
- package/src/RedactoPrivacyCenter/ui/PCText.tsx +0 -41
- package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +0 -43
- package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +0 -37
- package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +0 -121
- package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +0 -25
- package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +0 -53
- package/src/RedactoPrivacyCenter/ui/index.ts +0 -18
- package/src/RedactoPrivacyCenter/utils/cn.ts +0 -28
- package/src/RedactoPrivacyCenter/utils/date.ts +0 -48
- package/src/RedactoPrivacyCenter/utils/status.ts +0 -111
- package/src/RedactoPrivacyCenter/utils/toast.ts +0 -130
- package/src/index.ts +0 -19
- package/src/privacy-center.ts +0 -113
- package/src/shared/api-errors.ts +0 -180
- package/src/shared/notice-consent-status.ts +0 -190
- package/src/shared/sandbox.ts +0 -6
- package/src/types/css.d.ts +0 -4
- package/src/types/images.d.ts +0 -19
- package/src/types/react-jsx.d.ts +0 -26
- package/tests/DataAvailabilityContext.test.tsx +0 -123
- package/tests/Form.test.tsx +0 -1424
- package/tests/ManageConsentErrorMapping.test.tsx +0 -85
- package/tests/ProductSelectionSection.test.tsx +0 -328
- package/tests/RequestSubmit.test.tsx +0 -135
- package/tests/RevocablePurpose.test.tsx +0 -146
- package/tests/RevokeWarningMessage.test.tsx +0 -151
- package/tests/consentClient.test.ts +0 -217
- package/tests/manageConsentServer.test.ts +0 -38
- package/tests/mocks.ts +0 -257
- package/tests/resolveContactFromJwt.test.ts +0 -44
- package/tests/setup.ts +0 -16
- package/tsconfig.json +0 -16
- package/tsup.config.ts +0 -31
- package/vitest.config.ts +0 -16
|
@@ -1,4308 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Redacto Notice Consent Component
|
|
3
|
-
*
|
|
4
|
-
* A comprehensive consent management component that handles both initial consent
|
|
5
|
-
* collection and reconsent flows with visual distinction between already-consented
|
|
6
|
-
* and needs-consent purposes.
|
|
7
|
-
*/
|
|
8
|
-
// @ts-nocheck - React 19 @types/react has Element/ReactNode assignability issues; tsc passes
|
|
9
|
-
// React imports
|
|
10
|
-
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
-
|
|
12
|
-
// API imports
|
|
13
|
-
import {
|
|
14
|
-
buildLedgerConsentOverlay,
|
|
15
|
-
clearApiCache,
|
|
16
|
-
fetchConsentContent,
|
|
17
|
-
fetchNoticeConsentStatus,
|
|
18
|
-
fetchTTSAudioUrls,
|
|
19
|
-
initiateGuardianVerification,
|
|
20
|
-
submitConsentEvent,
|
|
21
|
-
verifyGuardianStatus,
|
|
22
|
-
} from "./api";
|
|
23
|
-
|
|
24
|
-
// Type imports
|
|
25
|
-
import type {
|
|
26
|
-
ConsentContent,
|
|
27
|
-
PurposeItemData,
|
|
28
|
-
PurposeItemProps,
|
|
29
|
-
} from "./api/types";
|
|
30
|
-
import type { Props, TranslationObject } from "./types";
|
|
31
|
-
|
|
32
|
-
// Asset imports
|
|
33
|
-
import logo from "./assets/redacto-logo.png";
|
|
34
|
-
|
|
35
|
-
// Constants
|
|
36
|
-
import { DPO_AUDIO_ELEMENT_MAP } from "./constants";
|
|
37
|
-
import { toBcp47Code, toNativeLabel } from "./language-codes";
|
|
38
|
-
|
|
39
|
-
// Utility imports
|
|
40
|
-
import { injectCheckboxStyles } from "./injectStyles";
|
|
41
|
-
import { styles } from "./styles";
|
|
42
|
-
import { useMediaQuery } from "./useMediaQuery";
|
|
43
|
-
|
|
44
|
-
// Storage service for redirect flow
|
|
45
|
-
import { clearConsentState, getConsentState, saveConsentState } from "./services/storage";
|
|
46
|
-
|
|
47
|
-
// =============================================================================
|
|
48
|
-
// SUB-COMPONENTS
|
|
49
|
-
// =============================================================================
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* PurposeItem Component
|
|
53
|
-
*
|
|
54
|
-
* Displays an individual purpose with its data elements.
|
|
55
|
-
* Supports both regular consent and reconsent UI modes.
|
|
56
|
-
*/
|
|
57
|
-
const PurposeItem: React.FC<
|
|
58
|
-
PurposeItemProps & {
|
|
59
|
-
selectedLanguage: string;
|
|
60
|
-
initialDataElementSelections?: Record<string, boolean>;
|
|
61
|
-
}
|
|
62
|
-
> = ({
|
|
63
|
-
purpose,
|
|
64
|
-
selectedPurposes,
|
|
65
|
-
collapsedPurposes,
|
|
66
|
-
selectedDataElements,
|
|
67
|
-
settings,
|
|
68
|
-
styles,
|
|
69
|
-
responsiveStyles,
|
|
70
|
-
onPurposeToggle,
|
|
71
|
-
onPurposeCollapse,
|
|
72
|
-
onDataElementToggle,
|
|
73
|
-
getTranslatedText,
|
|
74
|
-
isAlreadyConsented,
|
|
75
|
-
selectedLanguage,
|
|
76
|
-
initialDataElementSelections = {},
|
|
77
|
-
}) => {
|
|
78
|
-
const headingStyle = useMemo(
|
|
79
|
-
() => ({
|
|
80
|
-
color: settings?.headingColor || "#323B4B",
|
|
81
|
-
fontFamily: settings?.font || "inherit",
|
|
82
|
-
}),
|
|
83
|
-
[settings]
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
const textStyle = useMemo(
|
|
87
|
-
() => ({
|
|
88
|
-
color: settings?.textColor || "#344054",
|
|
89
|
-
fontFamily: settings?.font || "inherit",
|
|
90
|
-
}),
|
|
91
|
-
[settings]
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
<div key={purpose.uuid}>
|
|
96
|
-
<div style={styles.optionItem}>
|
|
97
|
-
<div
|
|
98
|
-
style={styles.optionLeft}
|
|
99
|
-
onClick={() => onPurposeCollapse(purpose.uuid)}
|
|
100
|
-
role="button"
|
|
101
|
-
tabIndex={0}
|
|
102
|
-
aria-expanded={!collapsedPurposes[purpose.uuid]}
|
|
103
|
-
aria-controls={`purpose-${purpose.uuid}`}
|
|
104
|
-
aria-label={`${collapsedPurposes[purpose.uuid] ? "Expand" : "Collapse"} ${getTranslatedText(
|
|
105
|
-
`purposes.name`,
|
|
106
|
-
purpose.name,
|
|
107
|
-
purpose.uuid
|
|
108
|
-
)} details`}
|
|
109
|
-
onKeyDown={(e) => {
|
|
110
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
111
|
-
e.preventDefault();
|
|
112
|
-
onPurposeCollapse(purpose.uuid);
|
|
113
|
-
}
|
|
114
|
-
}}
|
|
115
|
-
>
|
|
116
|
-
<div
|
|
117
|
-
style={{
|
|
118
|
-
display: "flex",
|
|
119
|
-
alignItems: "center",
|
|
120
|
-
justifyContent: "center",
|
|
121
|
-
marginLeft: "5px",
|
|
122
|
-
}}
|
|
123
|
-
>
|
|
124
|
-
<svg
|
|
125
|
-
width="7"
|
|
126
|
-
height="12"
|
|
127
|
-
viewBox="0 0 7 12"
|
|
128
|
-
fill="none"
|
|
129
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
130
|
-
stroke={settings?.headingColor || "#323B4B"}
|
|
131
|
-
strokeWidth="2"
|
|
132
|
-
strokeLinecap={"round" as const}
|
|
133
|
-
strokeLinejoin={"round" as const}
|
|
134
|
-
style={{
|
|
135
|
-
transform: !collapsedPurposes[purpose.uuid]
|
|
136
|
-
? "rotate(90deg)"
|
|
137
|
-
: "rotate(0deg)",
|
|
138
|
-
transition: "transform 0.3s ease",
|
|
139
|
-
}}
|
|
140
|
-
aria-hidden="true"
|
|
141
|
-
>
|
|
142
|
-
<path d="M1 1L6 6L1 11" />
|
|
143
|
-
</svg>
|
|
144
|
-
</div>
|
|
145
|
-
<div style={styles.optionTextContainer}>
|
|
146
|
-
<h2
|
|
147
|
-
key={`purpose-name-${purpose.uuid}-${selectedLanguage}`}
|
|
148
|
-
id={`purpose-name-${purpose.uuid}`}
|
|
149
|
-
style={{
|
|
150
|
-
...styles.optionTitle,
|
|
151
|
-
...responsiveStyles.optionTitle,
|
|
152
|
-
...headingStyle,
|
|
153
|
-
}}
|
|
154
|
-
>
|
|
155
|
-
{getTranslatedText(`purposes.name`, purpose.name, purpose.uuid)}
|
|
156
|
-
</h2>
|
|
157
|
-
<h3
|
|
158
|
-
key={`purpose-description-${purpose.uuid}-${selectedLanguage}`}
|
|
159
|
-
id={`purpose-description-${purpose.uuid}`}
|
|
160
|
-
style={{
|
|
161
|
-
...styles.optionDescription,
|
|
162
|
-
...responsiveStyles.optionDescription,
|
|
163
|
-
...textStyle,
|
|
164
|
-
}}
|
|
165
|
-
>
|
|
166
|
-
{getTranslatedText(
|
|
167
|
-
`purposes.description`,
|
|
168
|
-
purpose.description,
|
|
169
|
-
purpose.uuid
|
|
170
|
-
)}
|
|
171
|
-
</h3>
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
{!isAlreadyConsented && (
|
|
175
|
-
<input
|
|
176
|
-
className="redacto-checkbox-large"
|
|
177
|
-
type="checkbox"
|
|
178
|
-
checked={selectedPurposes[purpose.uuid] || false}
|
|
179
|
-
onChange={() => onPurposeToggle(purpose.uuid)}
|
|
180
|
-
aria-label={`Select all data elements for ${getTranslatedText(
|
|
181
|
-
`purposes.name`,
|
|
182
|
-
purpose.name,
|
|
183
|
-
purpose.uuid
|
|
184
|
-
)}`}
|
|
185
|
-
/>
|
|
186
|
-
)}
|
|
187
|
-
{isAlreadyConsented && (
|
|
188
|
-
<div
|
|
189
|
-
style={{
|
|
190
|
-
display: "flex",
|
|
191
|
-
alignItems: "center",
|
|
192
|
-
justifyContent: "center",
|
|
193
|
-
width: "20px",
|
|
194
|
-
height: "20px",
|
|
195
|
-
borderRadius: "50%",
|
|
196
|
-
backgroundColor: "#10B981",
|
|
197
|
-
marginLeft: "12px",
|
|
198
|
-
}}
|
|
199
|
-
aria-label={`Already consented to ${getTranslatedText(
|
|
200
|
-
`purposes.name`,
|
|
201
|
-
purpose.name,
|
|
202
|
-
purpose.uuid
|
|
203
|
-
)}`}
|
|
204
|
-
>
|
|
205
|
-
<svg
|
|
206
|
-
width="12"
|
|
207
|
-
height="12"
|
|
208
|
-
viewBox="0 0 12 12"
|
|
209
|
-
fill="none"
|
|
210
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
211
|
-
aria-hidden="true"
|
|
212
|
-
>
|
|
213
|
-
<path
|
|
214
|
-
d="M4 6L5.5 7.5L8.5 4.5"
|
|
215
|
-
stroke="white"
|
|
216
|
-
strokeWidth="2"
|
|
217
|
-
strokeLinecap="round"
|
|
218
|
-
strokeLinejoin="round"
|
|
219
|
-
/>
|
|
220
|
-
</svg>
|
|
221
|
-
</div>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
{!collapsedPurposes[purpose.uuid] &&
|
|
225
|
-
purpose.data_elements &&
|
|
226
|
-
purpose.data_elements.length > 0 && (
|
|
227
|
-
<div
|
|
228
|
-
key={`purpose-${purpose.uuid}-container-${selectedLanguage}`}
|
|
229
|
-
id={`purpose-${purpose.uuid}`}
|
|
230
|
-
style={styles.dataElementsContainer}
|
|
231
|
-
>
|
|
232
|
-
{purpose.data_elements.map((dataElement: any) => (
|
|
233
|
-
<div
|
|
234
|
-
key={`${dataElement.uuid}-${selectedLanguage}`}
|
|
235
|
-
style={styles.dataElementItem}
|
|
236
|
-
>
|
|
237
|
-
<span
|
|
238
|
-
key={`data-element-${purpose.uuid}-${dataElement.uuid}-${selectedLanguage}`}
|
|
239
|
-
id={`data-element-${purpose.uuid}-${dataElement.uuid}`}
|
|
240
|
-
style={{
|
|
241
|
-
...styles.dataElementText,
|
|
242
|
-
...responsiveStyles.dataElementText,
|
|
243
|
-
...textStyle,
|
|
244
|
-
}}
|
|
245
|
-
>
|
|
246
|
-
{getTranslatedText(
|
|
247
|
-
`data_elements.name`,
|
|
248
|
-
dataElement.name,
|
|
249
|
-
dataElement.uuid
|
|
250
|
-
)}
|
|
251
|
-
{dataElement.required && (
|
|
252
|
-
<span
|
|
253
|
-
style={{
|
|
254
|
-
color: "red",
|
|
255
|
-
marginLeft: "4px",
|
|
256
|
-
}}
|
|
257
|
-
aria-label="required"
|
|
258
|
-
>
|
|
259
|
-
*
|
|
260
|
-
</span>
|
|
261
|
-
)}
|
|
262
|
-
</span>
|
|
263
|
-
{!isAlreadyConsented && (
|
|
264
|
-
<input
|
|
265
|
-
className="redacto-checkbox-small"
|
|
266
|
-
type="checkbox"
|
|
267
|
-
checked={
|
|
268
|
-
selectedDataElements[
|
|
269
|
-
`${purpose.uuid}-${dataElement.uuid}`
|
|
270
|
-
] || false
|
|
271
|
-
}
|
|
272
|
-
onChange={() =>
|
|
273
|
-
onDataElementToggle(dataElement.uuid, purpose.uuid)
|
|
274
|
-
}
|
|
275
|
-
aria-label={`Select ${getTranslatedText(
|
|
276
|
-
`data_elements.name`,
|
|
277
|
-
dataElement.name,
|
|
278
|
-
dataElement.uuid
|
|
279
|
-
)}${dataElement.required ? " (required)" : ""}`}
|
|
280
|
-
/>
|
|
281
|
-
)}
|
|
282
|
-
{isAlreadyConsented && (
|
|
283
|
-
<>
|
|
284
|
-
{(() => {
|
|
285
|
-
const combinedId = `${purpose.uuid}-${dataElement.uuid}`;
|
|
286
|
-
const wasOriginallySelected =
|
|
287
|
-
initialDataElementSelections[combinedId] || false;
|
|
288
|
-
|
|
289
|
-
// Review mode: read-only display of what user consented to
|
|
290
|
-
// Green tick = consented, empty circle = not consented (no editing)
|
|
291
|
-
if (wasOriginallySelected) {
|
|
292
|
-
return (
|
|
293
|
-
<div
|
|
294
|
-
style={{
|
|
295
|
-
display: "flex",
|
|
296
|
-
alignItems: "center",
|
|
297
|
-
justifyContent: "center",
|
|
298
|
-
width: "16px",
|
|
299
|
-
height: "16px",
|
|
300
|
-
borderRadius: "50%",
|
|
301
|
-
backgroundColor: "#10B981",
|
|
302
|
-
}}
|
|
303
|
-
aria-label={`Consented to ${getTranslatedText(
|
|
304
|
-
`data_elements.name`,
|
|
305
|
-
dataElement.name,
|
|
306
|
-
dataElement.uuid
|
|
307
|
-
)}`}
|
|
308
|
-
>
|
|
309
|
-
<svg
|
|
310
|
-
width="10"
|
|
311
|
-
height="10"
|
|
312
|
-
viewBox="0 0 10 10"
|
|
313
|
-
fill="none"
|
|
314
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
315
|
-
aria-hidden="true"
|
|
316
|
-
>
|
|
317
|
-
<path
|
|
318
|
-
d="M3 5L4.5 6.5L7.5 3.5"
|
|
319
|
-
stroke="white"
|
|
320
|
-
strokeWidth="1.5"
|
|
321
|
-
strokeLinecap={"round" as const}
|
|
322
|
-
strokeLinejoin={"round" as const}
|
|
323
|
-
/>
|
|
324
|
-
</svg>
|
|
325
|
-
</div>
|
|
326
|
-
);
|
|
327
|
-
} else {
|
|
328
|
-
// Not consented: show empty circle (read-only, no checkbox)
|
|
329
|
-
return (
|
|
330
|
-
<div
|
|
331
|
-
style={{
|
|
332
|
-
display: "flex",
|
|
333
|
-
alignItems: "center",
|
|
334
|
-
justifyContent: "center",
|
|
335
|
-
width: "16px",
|
|
336
|
-
height: "16px",
|
|
337
|
-
borderRadius: "50%",
|
|
338
|
-
border: "2px solid #d0d5dd",
|
|
339
|
-
backgroundColor: "transparent",
|
|
340
|
-
}}
|
|
341
|
-
aria-label={`Not consented to ${getTranslatedText(
|
|
342
|
-
`data_elements.name`,
|
|
343
|
-
dataElement.name,
|
|
344
|
-
dataElement.uuid
|
|
345
|
-
)}`}
|
|
346
|
-
/>
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
})()}
|
|
350
|
-
</>
|
|
351
|
-
)}
|
|
352
|
-
</div>
|
|
353
|
-
))}
|
|
354
|
-
</div>
|
|
355
|
-
)}
|
|
356
|
-
</div>
|
|
357
|
-
);
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* GuardianForm Component
|
|
362
|
-
*
|
|
363
|
-
* Displays a form for collecting guardian information for minor consent.
|
|
364
|
-
*/
|
|
365
|
-
const RELATIONSHIP_OPTIONS = [
|
|
366
|
-
{ value: "Father", label: "Father" },
|
|
367
|
-
{ value: "Mother", label: "Mother" },
|
|
368
|
-
{ value: "Legal Guardian", label: "Legal Guardian" },
|
|
369
|
-
{ value: "Grandparent", label: "Grandparent" },
|
|
370
|
-
];
|
|
371
|
-
|
|
372
|
-
const GuardianForm: React.FC<{
|
|
373
|
-
formData: {
|
|
374
|
-
guardianName: string;
|
|
375
|
-
guardianContact: string;
|
|
376
|
-
guardianRelationship: string;
|
|
377
|
-
};
|
|
378
|
-
errors: Record<string, string>;
|
|
379
|
-
isSubmitting: boolean;
|
|
380
|
-
onChange: (field: string, value: string) => void;
|
|
381
|
-
onNext: () => void;
|
|
382
|
-
onDecline: () => void;
|
|
383
|
-
styles: any;
|
|
384
|
-
responsiveStyles: any;
|
|
385
|
-
settings?: any;
|
|
386
|
-
componentHeadingStyle: any;
|
|
387
|
-
componentTextStyle: any;
|
|
388
|
-
acceptButtonStyle: any;
|
|
389
|
-
declineButtonStyle: any;
|
|
390
|
-
logoUrl: string;
|
|
391
|
-
}> = ({
|
|
392
|
-
formData,
|
|
393
|
-
errors,
|
|
394
|
-
isSubmitting,
|
|
395
|
-
onChange,
|
|
396
|
-
onNext,
|
|
397
|
-
onDecline,
|
|
398
|
-
styles,
|
|
399
|
-
responsiveStyles,
|
|
400
|
-
settings,
|
|
401
|
-
componentHeadingStyle,
|
|
402
|
-
componentTextStyle,
|
|
403
|
-
acceptButtonStyle,
|
|
404
|
-
declineButtonStyle,
|
|
405
|
-
logoUrl,
|
|
406
|
-
}) => {
|
|
407
|
-
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
408
|
-
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
409
|
-
const [openUpward, setOpenUpward] = useState(false);
|
|
410
|
-
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
411
|
-
const optionsRef = useRef<HTMLDivElement>(null);
|
|
412
|
-
|
|
413
|
-
// Close dropdown on outside click
|
|
414
|
-
useEffect(() => {
|
|
415
|
-
if (!isDropdownOpen) return;
|
|
416
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
417
|
-
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
418
|
-
setIsDropdownOpen(false);
|
|
419
|
-
setFocusedIndex(-1);
|
|
420
|
-
}
|
|
421
|
-
};
|
|
422
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
423
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
424
|
-
}, [isDropdownOpen]);
|
|
425
|
-
|
|
426
|
-
// Determine direction on open
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
if (!isDropdownOpen || !dropdownRef.current) return;
|
|
429
|
-
const rect = dropdownRef.current.getBoundingClientRect();
|
|
430
|
-
const spaceBelow = window.innerHeight - rect.bottom;
|
|
431
|
-
const dropdownHeight = 220; // approximate max height of options panel
|
|
432
|
-
setOpenUpward(spaceBelow < dropdownHeight && rect.top > dropdownHeight);
|
|
433
|
-
}, [isDropdownOpen]);
|
|
434
|
-
|
|
435
|
-
const handleSelect = (value: string) => {
|
|
436
|
-
onChange("guardianRelationship", value);
|
|
437
|
-
setIsDropdownOpen(false);
|
|
438
|
-
setFocusedIndex(-1);
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
442
|
-
if (!isDropdownOpen) {
|
|
443
|
-
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
444
|
-
e.preventDefault();
|
|
445
|
-
setIsDropdownOpen(true);
|
|
446
|
-
setFocusedIndex(0);
|
|
447
|
-
}
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
switch (e.key) {
|
|
451
|
-
case "ArrowDown":
|
|
452
|
-
e.preventDefault();
|
|
453
|
-
setFocusedIndex((prev) => Math.min(prev + 1, RELATIONSHIP_OPTIONS.length - 1));
|
|
454
|
-
break;
|
|
455
|
-
case "ArrowUp":
|
|
456
|
-
e.preventDefault();
|
|
457
|
-
setFocusedIndex((prev) => Math.max(prev - 1, 0));
|
|
458
|
-
break;
|
|
459
|
-
case "Enter":
|
|
460
|
-
case " ":
|
|
461
|
-
e.preventDefault();
|
|
462
|
-
if (focusedIndex >= 0) handleSelect(RELATIONSHIP_OPTIONS[focusedIndex].value);
|
|
463
|
-
break;
|
|
464
|
-
case "Escape":
|
|
465
|
-
case "Tab":
|
|
466
|
-
setIsDropdownOpen(false);
|
|
467
|
-
setFocusedIndex(-1);
|
|
468
|
-
break;
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
// Scroll focused option into view
|
|
473
|
-
useEffect(() => {
|
|
474
|
-
if (focusedIndex >= 0 && optionsRef.current) {
|
|
475
|
-
const option = optionsRef.current.children[focusedIndex] as HTMLElement;
|
|
476
|
-
option?.scrollIntoView?.({ block: "nearest" });
|
|
477
|
-
}
|
|
478
|
-
}, [focusedIndex]);
|
|
479
|
-
|
|
480
|
-
const borderColor = settings?.borderColor || "#d0d5dd";
|
|
481
|
-
const textColor = settings?.textColor || "#344054";
|
|
482
|
-
const bgColor = settings?.backgroundColor || "#ffffff";
|
|
483
|
-
const radius = settings?.borderRadius || "8px";
|
|
484
|
-
const font = settings?.font || "inherit";
|
|
485
|
-
|
|
486
|
-
const inputStyle = {
|
|
487
|
-
width: "100%",
|
|
488
|
-
padding: "10px 12px",
|
|
489
|
-
border: `1px solid ${borderColor}`,
|
|
490
|
-
borderRadius: radius,
|
|
491
|
-
fontSize: "13px",
|
|
492
|
-
fontFamily: font,
|
|
493
|
-
color: textColor,
|
|
494
|
-
backgroundColor: bgColor,
|
|
495
|
-
marginBottom: "2px",
|
|
496
|
-
boxSizing: "border-box" as const,
|
|
497
|
-
outline: "none",
|
|
498
|
-
};
|
|
499
|
-
|
|
500
|
-
const errorStyle = {
|
|
501
|
-
color: "#DC2626",
|
|
502
|
-
fontSize: "11px",
|
|
503
|
-
marginTop: "4px",
|
|
504
|
-
marginBottom: "0px",
|
|
505
|
-
};
|
|
506
|
-
|
|
507
|
-
const labelStyle = {
|
|
508
|
-
...componentTextStyle,
|
|
509
|
-
fontSize: "13px",
|
|
510
|
-
fontWeight: "500" as const,
|
|
511
|
-
marginBottom: "4px",
|
|
512
|
-
display: "block",
|
|
513
|
-
};
|
|
514
|
-
|
|
515
|
-
const hintStyle = {
|
|
516
|
-
fontSize: "11px",
|
|
517
|
-
color: "#667085",
|
|
518
|
-
marginTop: "2px",
|
|
519
|
-
marginBottom: "0px",
|
|
520
|
-
lineHeight: "1.4",
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
const infoBannerStyle = {
|
|
524
|
-
display: "flex",
|
|
525
|
-
alignItems: "flex-start",
|
|
526
|
-
gap: "8px",
|
|
527
|
-
padding: "10px 12px",
|
|
528
|
-
backgroundColor: "#EFF6FF",
|
|
529
|
-
border: "1px solid #BFDBFE",
|
|
530
|
-
borderRadius: radius,
|
|
531
|
-
marginBottom: "16px",
|
|
532
|
-
fontSize: "12px",
|
|
533
|
-
lineHeight: "1.5",
|
|
534
|
-
color: "#1E40AF",
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
const selectedLabel = RELATIONSHIP_OPTIONS.find((o) => o.value === formData.guardianRelationship)?.label;
|
|
538
|
-
const hasError = Boolean(errors.guardianRelationship);
|
|
539
|
-
|
|
540
|
-
return (
|
|
541
|
-
<>
|
|
542
|
-
<div style={{ ...styles.topSection, ...componentTextStyle }}>
|
|
543
|
-
<div style={styles.topLeft}>
|
|
544
|
-
<img style={styles.logo} src={logoUrl} alt="Logo" />
|
|
545
|
-
<h2
|
|
546
|
-
id="guardian-form-title"
|
|
547
|
-
style={{
|
|
548
|
-
...styles.title,
|
|
549
|
-
...responsiveStyles.title,
|
|
550
|
-
...componentHeadingStyle,
|
|
551
|
-
fontSize: "16px",
|
|
552
|
-
fontWeight: 600,
|
|
553
|
-
}}
|
|
554
|
-
>
|
|
555
|
-
Guardian Verification
|
|
556
|
-
</h2>
|
|
557
|
-
</div>
|
|
558
|
-
</div>
|
|
559
|
-
|
|
560
|
-
<div
|
|
561
|
-
style={{
|
|
562
|
-
...styles.middleSection,
|
|
563
|
-
...responsiveStyles.middleSection,
|
|
564
|
-
...componentTextStyle,
|
|
565
|
-
}}
|
|
566
|
-
>
|
|
567
|
-
<p
|
|
568
|
-
style={{
|
|
569
|
-
...styles.privacyText,
|
|
570
|
-
...responsiveStyles.privacyText,
|
|
571
|
-
...componentTextStyle,
|
|
572
|
-
fontSize: "13px",
|
|
573
|
-
lineHeight: "1.5",
|
|
574
|
-
marginBottom: "12px",
|
|
575
|
-
}}
|
|
576
|
-
>
|
|
577
|
-
Since you are under the age of consent, a legal guardian must verify
|
|
578
|
-
their identity via DigiLocker before you can proceed.
|
|
579
|
-
</p>
|
|
580
|
-
|
|
581
|
-
<div style={infoBannerStyle}>
|
|
582
|
-
<svg
|
|
583
|
-
width="16"
|
|
584
|
-
height="16"
|
|
585
|
-
viewBox="0 0 24 24"
|
|
586
|
-
fill="none"
|
|
587
|
-
stroke="#2563EB"
|
|
588
|
-
strokeWidth="2"
|
|
589
|
-
strokeLinecap="round"
|
|
590
|
-
strokeLinejoin="round"
|
|
591
|
-
style={{ flexShrink: 0, marginTop: "1px" }}
|
|
592
|
-
>
|
|
593
|
-
<circle cx="12" cy="12" r="10" />
|
|
594
|
-
<line x1="12" y1="16" x2="12" y2="12" />
|
|
595
|
-
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
596
|
-
</svg>
|
|
597
|
-
<span>
|
|
598
|
-
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.
|
|
599
|
-
</span>
|
|
600
|
-
</div>
|
|
601
|
-
|
|
602
|
-
<div
|
|
603
|
-
style={{
|
|
604
|
-
display: "flex",
|
|
605
|
-
flexDirection: "column",
|
|
606
|
-
gap: "14px",
|
|
607
|
-
}}
|
|
608
|
-
>
|
|
609
|
-
<div>
|
|
610
|
-
<label htmlFor="guardianName" style={labelStyle}>
|
|
611
|
-
Guardian's Full Name <span style={{ color: "#DC2626" }}>*</span>
|
|
612
|
-
</label>
|
|
613
|
-
<input
|
|
614
|
-
id="guardianName"
|
|
615
|
-
type="text"
|
|
616
|
-
value={formData.guardianName}
|
|
617
|
-
onChange={(e) => onChange("guardianName", e.target.value)}
|
|
618
|
-
style={{
|
|
619
|
-
...inputStyle,
|
|
620
|
-
borderColor: errors.guardianName ? "#DC2626" : borderColor,
|
|
621
|
-
}}
|
|
622
|
-
placeholder="e.g. Rajesh Kumar"
|
|
623
|
-
autoComplete="name"
|
|
624
|
-
aria-describedby="guardianName-hint"
|
|
625
|
-
/>
|
|
626
|
-
<p id="guardianName-hint" style={hintStyle}>
|
|
627
|
-
Enter the full name as per government ID (Aadhaar, PAN, etc.)
|
|
628
|
-
</p>
|
|
629
|
-
{errors.guardianName && (
|
|
630
|
-
<div style={errorStyle} role="alert">{errors.guardianName}</div>
|
|
631
|
-
)}
|
|
632
|
-
</div>
|
|
633
|
-
|
|
634
|
-
<div>
|
|
635
|
-
<label htmlFor="guardianContact" style={labelStyle}>
|
|
636
|
-
Guardian's Phone Number <span style={{ color: "#DC2626" }}>*</span>
|
|
637
|
-
</label>
|
|
638
|
-
<input
|
|
639
|
-
id="guardianContact"
|
|
640
|
-
type="tel"
|
|
641
|
-
inputMode="tel"
|
|
642
|
-
value={formData.guardianContact}
|
|
643
|
-
onChange={(e) => onChange("guardianContact", e.target.value)}
|
|
644
|
-
style={{
|
|
645
|
-
...inputStyle,
|
|
646
|
-
borderColor: errors.guardianContact ? "#DC2626" : borderColor,
|
|
647
|
-
}}
|
|
648
|
-
placeholder="e.g. 9876543210"
|
|
649
|
-
autoComplete="tel"
|
|
650
|
-
aria-describedby="guardianContact-hint"
|
|
651
|
-
/>
|
|
652
|
-
<p id="guardianContact-hint" style={hintStyle}>
|
|
653
|
-
Mobile number linked to guardian's Aadhaar
|
|
654
|
-
</p>
|
|
655
|
-
{errors.guardianContact && (
|
|
656
|
-
<div style={errorStyle} role="alert">{errors.guardianContact}</div>
|
|
657
|
-
)}
|
|
658
|
-
</div>
|
|
659
|
-
|
|
660
|
-
<div ref={dropdownRef} style={{ position: "relative" }}>
|
|
661
|
-
<label id="guardianRelationship-label" style={labelStyle}>
|
|
662
|
-
Relationship <span style={{ color: "#DC2626" }}>*</span>
|
|
663
|
-
</label>
|
|
664
|
-
<div
|
|
665
|
-
role="combobox"
|
|
666
|
-
aria-expanded={isDropdownOpen}
|
|
667
|
-
aria-haspopup="listbox"
|
|
668
|
-
aria-labelledby="guardianRelationship-label"
|
|
669
|
-
aria-controls="guardianRelationship-listbox"
|
|
670
|
-
aria-activedescendant={focusedIndex >= 0 ? `guardianRelationship-option-${focusedIndex}` : undefined}
|
|
671
|
-
tabIndex={0}
|
|
672
|
-
onClick={() => { setIsDropdownOpen((prev) => !prev); setFocusedIndex(-1); }}
|
|
673
|
-
onKeyDown={handleKeyDown}
|
|
674
|
-
style={{
|
|
675
|
-
...inputStyle,
|
|
676
|
-
borderColor: hasError ? "#DC2626" : isDropdownOpen ? (settings?.button?.accept?.backgroundColor || "#3b82f6") : borderColor,
|
|
677
|
-
boxShadow: isDropdownOpen ? `0 0 0 1px ${settings?.button?.accept?.backgroundColor || "#3b82f6"}` : "none",
|
|
678
|
-
color: selectedLabel ? textColor : "#9CA3AF",
|
|
679
|
-
cursor: "pointer",
|
|
680
|
-
display: "flex",
|
|
681
|
-
alignItems: "center",
|
|
682
|
-
justifyContent: "space-between",
|
|
683
|
-
userSelect: "none",
|
|
684
|
-
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
685
|
-
}}
|
|
686
|
-
>
|
|
687
|
-
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
688
|
-
{selectedLabel || "Select relationship"}
|
|
689
|
-
</span>
|
|
690
|
-
<svg
|
|
691
|
-
width="14"
|
|
692
|
-
height="14"
|
|
693
|
-
viewBox="0 0 24 24"
|
|
694
|
-
fill="none"
|
|
695
|
-
stroke="#667085"
|
|
696
|
-
strokeWidth="2"
|
|
697
|
-
strokeLinecap="round"
|
|
698
|
-
strokeLinejoin="round"
|
|
699
|
-
style={{
|
|
700
|
-
flexShrink: 0,
|
|
701
|
-
marginLeft: "8px",
|
|
702
|
-
transform: isDropdownOpen ? "rotate(180deg)" : "rotate(0deg)",
|
|
703
|
-
transition: "transform 0.2s",
|
|
704
|
-
}}
|
|
705
|
-
>
|
|
706
|
-
<polyline points="6 9 12 15 18 9" />
|
|
707
|
-
</svg>
|
|
708
|
-
</div>
|
|
709
|
-
|
|
710
|
-
{isDropdownOpen && (
|
|
711
|
-
<div
|
|
712
|
-
ref={optionsRef}
|
|
713
|
-
id="guardianRelationship-listbox"
|
|
714
|
-
role="listbox"
|
|
715
|
-
aria-labelledby="guardianRelationship-label"
|
|
716
|
-
style={{
|
|
717
|
-
position: "absolute",
|
|
718
|
-
...(openUpward
|
|
719
|
-
? { bottom: "calc(100% + 4px)" }
|
|
720
|
-
: { top: "calc(100% + 4px)" }),
|
|
721
|
-
left: 0,
|
|
722
|
-
right: 0,
|
|
723
|
-
backgroundColor: bgColor,
|
|
724
|
-
border: `1px solid ${borderColor}`,
|
|
725
|
-
borderRadius: radius,
|
|
726
|
-
boxShadow: openUpward
|
|
727
|
-
? "0 -4px 12px rgba(0, 0, 0, 0.1), 0 -1px 3px rgba(0, 0, 0, 0.06)"
|
|
728
|
-
: "0 4px 12px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06)",
|
|
729
|
-
zIndex: 50,
|
|
730
|
-
overflow: "hidden",
|
|
731
|
-
padding: "4px 0",
|
|
732
|
-
maxHeight: "220px",
|
|
733
|
-
overflowY: "auto",
|
|
734
|
-
}}
|
|
735
|
-
>
|
|
736
|
-
{RELATIONSHIP_OPTIONS.map((option, index) => {
|
|
737
|
-
const isSelected = formData.guardianRelationship === option.value;
|
|
738
|
-
const isFocused = focusedIndex === index;
|
|
739
|
-
const accentColor = settings?.button?.accept?.backgroundColor || "#3b82f6";
|
|
740
|
-
return (
|
|
741
|
-
<div
|
|
742
|
-
key={option.value}
|
|
743
|
-
id={`guardianRelationship-option-${index}`}
|
|
744
|
-
role="option"
|
|
745
|
-
aria-selected={isSelected}
|
|
746
|
-
onClick={() => handleSelect(option.value)}
|
|
747
|
-
onMouseEnter={() => setFocusedIndex(index)}
|
|
748
|
-
style={{
|
|
749
|
-
padding: "9px 12px",
|
|
750
|
-
fontSize: "13px",
|
|
751
|
-
fontFamily: font,
|
|
752
|
-
color: isSelected ? accentColor : textColor,
|
|
753
|
-
backgroundColor: isFocused ? "#F3F4F6" : "transparent",
|
|
754
|
-
cursor: "pointer",
|
|
755
|
-
display: "flex",
|
|
756
|
-
alignItems: "center",
|
|
757
|
-
justifyContent: "space-between",
|
|
758
|
-
transition: "background-color 0.1s",
|
|
759
|
-
}}
|
|
760
|
-
>
|
|
761
|
-
<span>{option.label}</span>
|
|
762
|
-
{isSelected && (
|
|
763
|
-
<svg
|
|
764
|
-
width="14"
|
|
765
|
-
height="14"
|
|
766
|
-
viewBox="0 0 24 24"
|
|
767
|
-
fill="none"
|
|
768
|
-
stroke={accentColor}
|
|
769
|
-
strokeWidth="2.5"
|
|
770
|
-
strokeLinecap="round"
|
|
771
|
-
strokeLinejoin="round"
|
|
772
|
-
style={{ flexShrink: 0 }}
|
|
773
|
-
>
|
|
774
|
-
<polyline points="20 6 9 17 4 12" />
|
|
775
|
-
</svg>
|
|
776
|
-
)}
|
|
777
|
-
</div>
|
|
778
|
-
);
|
|
779
|
-
})}
|
|
780
|
-
</div>
|
|
781
|
-
)}
|
|
782
|
-
|
|
783
|
-
{errors.guardianRelationship && (
|
|
784
|
-
<div style={errorStyle} role="alert">{errors.guardianRelationship}</div>
|
|
785
|
-
)}
|
|
786
|
-
</div>
|
|
787
|
-
</div>
|
|
788
|
-
|
|
789
|
-
{errors.general && (
|
|
790
|
-
<div
|
|
791
|
-
role="alert"
|
|
792
|
-
style={{
|
|
793
|
-
color: "#DC2626",
|
|
794
|
-
fontSize: "13px",
|
|
795
|
-
marginTop: "16px",
|
|
796
|
-
padding: "12px",
|
|
797
|
-
borderRadius: "6px",
|
|
798
|
-
border: "1px solid #FCA5A5",
|
|
799
|
-
backgroundColor: "#FEF2F2",
|
|
800
|
-
textAlign: "center",
|
|
801
|
-
}}
|
|
802
|
-
>
|
|
803
|
-
{errors.general}
|
|
804
|
-
</div>
|
|
805
|
-
)}
|
|
806
|
-
</div>
|
|
807
|
-
|
|
808
|
-
<div
|
|
809
|
-
style={{
|
|
810
|
-
...styles.bottomSection,
|
|
811
|
-
...responsiveStyles.bottomSection,
|
|
812
|
-
...componentTextStyle,
|
|
813
|
-
paddingTop: "12px",
|
|
814
|
-
}}
|
|
815
|
-
>
|
|
816
|
-
<button
|
|
817
|
-
style={{
|
|
818
|
-
...styles.button,
|
|
819
|
-
...responsiveStyles.button,
|
|
820
|
-
...styles.acceptButton,
|
|
821
|
-
...acceptButtonStyle,
|
|
822
|
-
}}
|
|
823
|
-
onClick={onNext}
|
|
824
|
-
disabled={isSubmitting}
|
|
825
|
-
type="button"
|
|
826
|
-
aria-label={
|
|
827
|
-
isSubmitting
|
|
828
|
-
? "Verifying guardian identity"
|
|
829
|
-
: "Proceed to guardian verification"
|
|
830
|
-
}
|
|
831
|
-
>
|
|
832
|
-
{isSubmitting ? "Verifying..." : "Verify via DigiLocker"}
|
|
833
|
-
</button>
|
|
834
|
-
<button
|
|
835
|
-
style={{
|
|
836
|
-
...styles.button,
|
|
837
|
-
...responsiveStyles.button,
|
|
838
|
-
...styles.cancelButton,
|
|
839
|
-
...declineButtonStyle,
|
|
840
|
-
}}
|
|
841
|
-
onClick={onDecline}
|
|
842
|
-
type="button"
|
|
843
|
-
aria-label="Cancel"
|
|
844
|
-
>
|
|
845
|
-
Cancel
|
|
846
|
-
</button>
|
|
847
|
-
</div>
|
|
848
|
-
</>
|
|
849
|
-
);
|
|
850
|
-
};
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* VerificationScreen Component
|
|
854
|
-
*
|
|
855
|
-
* Displays verification status while DigiLocker verification is in progress.
|
|
856
|
-
* No longer requires document type selection - handled by backend.
|
|
857
|
-
*/
|
|
858
|
-
const VerificationScreen: React.FC<{
|
|
859
|
-
onBack: (clearName?: boolean) => void;
|
|
860
|
-
onContinue: () => void;
|
|
861
|
-
isInitiating: boolean;
|
|
862
|
-
isPolling: boolean;
|
|
863
|
-
isVerificationComplete: boolean;
|
|
864
|
-
isAutoTransitioning: boolean;
|
|
865
|
-
error: string | null;
|
|
866
|
-
errorCode: string | null;
|
|
867
|
-
canRetry: boolean;
|
|
868
|
-
styles: any;
|
|
869
|
-
responsiveStyles: any;
|
|
870
|
-
settings?: any;
|
|
871
|
-
componentHeadingStyle: any;
|
|
872
|
-
componentTextStyle: any;
|
|
873
|
-
acceptButtonStyle: any;
|
|
874
|
-
declineButtonStyle: any;
|
|
875
|
-
logoUrl: string;
|
|
876
|
-
}> = ({
|
|
877
|
-
onBack,
|
|
878
|
-
onContinue,
|
|
879
|
-
isInitiating,
|
|
880
|
-
isPolling,
|
|
881
|
-
isVerificationComplete,
|
|
882
|
-
isAutoTransitioning,
|
|
883
|
-
error,
|
|
884
|
-
errorCode,
|
|
885
|
-
canRetry,
|
|
886
|
-
styles,
|
|
887
|
-
responsiveStyles,
|
|
888
|
-
settings,
|
|
889
|
-
componentHeadingStyle,
|
|
890
|
-
componentTextStyle,
|
|
891
|
-
acceptButtonStyle,
|
|
892
|
-
declineButtonStyle,
|
|
893
|
-
logoUrl,
|
|
894
|
-
}) => {
|
|
895
|
-
const errorStyle = {
|
|
896
|
-
color: "#DC2626",
|
|
897
|
-
fontSize: "14px",
|
|
898
|
-
marginTop: "16px",
|
|
899
|
-
padding: "12px",
|
|
900
|
-
borderRadius: "6px",
|
|
901
|
-
border: "1px solid #FCA5A5",
|
|
902
|
-
backgroundColor: "#FEF2F2",
|
|
903
|
-
textAlign: "center" as const,
|
|
904
|
-
};
|
|
905
|
-
|
|
906
|
-
const statusStyle = {
|
|
907
|
-
color: "#059669",
|
|
908
|
-
fontSize: "14px",
|
|
909
|
-
marginTop: "16px",
|
|
910
|
-
padding: "12px",
|
|
911
|
-
borderRadius: "6px",
|
|
912
|
-
border: "1px solid #A7F3D0",
|
|
913
|
-
backgroundColor: "#D1FAE5",
|
|
914
|
-
textAlign: "center" as const,
|
|
915
|
-
};
|
|
916
|
-
|
|
917
|
-
const spinnerStyle = {
|
|
918
|
-
width: "48px",
|
|
919
|
-
height: "48px",
|
|
920
|
-
border: "4px solid #e5e7eb",
|
|
921
|
-
borderTopColor: settings?.button?.accept?.backgroundColor || "#3b82f6",
|
|
922
|
-
borderRadius: "50%",
|
|
923
|
-
animation: "spin 1s linear infinite",
|
|
924
|
-
margin: "24px auto",
|
|
925
|
-
};
|
|
926
|
-
|
|
927
|
-
return (
|
|
928
|
-
<>
|
|
929
|
-
<div style={{ ...styles.topSection, ...componentTextStyle }}>
|
|
930
|
-
<div style={styles.topLeft}>
|
|
931
|
-
<img style={styles.logo} src={logoUrl} alt="Logo" />
|
|
932
|
-
<h2
|
|
933
|
-
id="verification-title"
|
|
934
|
-
style={{
|
|
935
|
-
...styles.title,
|
|
936
|
-
...responsiveStyles.title,
|
|
937
|
-
...componentHeadingStyle,
|
|
938
|
-
fontSize: "16px",
|
|
939
|
-
fontWeight: 600,
|
|
940
|
-
}}
|
|
941
|
-
>
|
|
942
|
-
Guardian Verification
|
|
943
|
-
</h2>
|
|
944
|
-
</div>
|
|
945
|
-
</div>
|
|
946
|
-
|
|
947
|
-
<div
|
|
948
|
-
style={{
|
|
949
|
-
...styles.middleSection,
|
|
950
|
-
...responsiveStyles.middleSection,
|
|
951
|
-
...componentTextStyle,
|
|
952
|
-
textAlign: "center" as const,
|
|
953
|
-
}}
|
|
954
|
-
>
|
|
955
|
-
{isVerificationComplete && !error ? (
|
|
956
|
-
<div
|
|
957
|
-
style={{
|
|
958
|
-
display: "flex",
|
|
959
|
-
flexDirection: "column",
|
|
960
|
-
alignItems: "center",
|
|
961
|
-
justifyContent: "center",
|
|
962
|
-
flex: 1,
|
|
963
|
-
}}
|
|
964
|
-
>
|
|
965
|
-
<div
|
|
966
|
-
style={{
|
|
967
|
-
width: "64px",
|
|
968
|
-
height: "64px",
|
|
969
|
-
borderRadius: "50%",
|
|
970
|
-
backgroundColor: "#D1FAE5",
|
|
971
|
-
display: "flex",
|
|
972
|
-
alignItems: "center",
|
|
973
|
-
justifyContent: "center",
|
|
974
|
-
}}
|
|
975
|
-
>
|
|
976
|
-
<svg
|
|
977
|
-
width="32"
|
|
978
|
-
height="32"
|
|
979
|
-
viewBox="0 0 24 24"
|
|
980
|
-
fill="none"
|
|
981
|
-
stroke="#059669"
|
|
982
|
-
strokeWidth="2"
|
|
983
|
-
strokeLinecap="round"
|
|
984
|
-
strokeLinejoin="round"
|
|
985
|
-
>
|
|
986
|
-
<polyline points="20 6 9 17 4 12" />
|
|
987
|
-
</svg>
|
|
988
|
-
</div>
|
|
989
|
-
<p
|
|
990
|
-
style={{
|
|
991
|
-
...componentHeadingStyle,
|
|
992
|
-
fontSize: "16px",
|
|
993
|
-
fontWeight: 600,
|
|
994
|
-
lineHeight: "1.5",
|
|
995
|
-
marginTop: "12px",
|
|
996
|
-
marginBottom: "24px",
|
|
997
|
-
}}
|
|
998
|
-
>
|
|
999
|
-
Verification Completed
|
|
1000
|
-
</p>
|
|
1001
|
-
</div>
|
|
1002
|
-
) : (isInitiating || isPolling) && !error ? (
|
|
1003
|
-
<>
|
|
1004
|
-
<div style={spinnerStyle} />
|
|
1005
|
-
<div style={statusStyle}>
|
|
1006
|
-
{isInitiating
|
|
1007
|
-
? "Opening DigiLocker in a new tab..."
|
|
1008
|
-
: "Please complete the verification in the DigiLocker tab. This page will update automatically when verification is complete."}
|
|
1009
|
-
</div>
|
|
1010
|
-
</>
|
|
1011
|
-
) : null}
|
|
1012
|
-
|
|
1013
|
-
{error && (
|
|
1014
|
-
<>
|
|
1015
|
-
<div
|
|
1016
|
-
style={{
|
|
1017
|
-
width: "64px",
|
|
1018
|
-
height: "64px",
|
|
1019
|
-
borderRadius: "50%",
|
|
1020
|
-
backgroundColor: "#fee2e2",
|
|
1021
|
-
display: "flex",
|
|
1022
|
-
alignItems: "center",
|
|
1023
|
-
justifyContent: "center",
|
|
1024
|
-
margin: "24px auto",
|
|
1025
|
-
}}
|
|
1026
|
-
>
|
|
1027
|
-
<svg
|
|
1028
|
-
width="32"
|
|
1029
|
-
height="32"
|
|
1030
|
-
viewBox="0 0 24 24"
|
|
1031
|
-
fill="none"
|
|
1032
|
-
stroke="#dc2626"
|
|
1033
|
-
strokeWidth="2"
|
|
1034
|
-
strokeLinecap="round"
|
|
1035
|
-
strokeLinejoin="round"
|
|
1036
|
-
>
|
|
1037
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
1038
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
1039
|
-
</svg>
|
|
1040
|
-
</div>
|
|
1041
|
-
<p
|
|
1042
|
-
style={{
|
|
1043
|
-
...styles.privacyText,
|
|
1044
|
-
...responsiveStyles.privacyText,
|
|
1045
|
-
...componentTextStyle,
|
|
1046
|
-
fontSize: "14px",
|
|
1047
|
-
lineHeight: "1.5",
|
|
1048
|
-
marginBottom: "12px",
|
|
1049
|
-
}}
|
|
1050
|
-
>
|
|
1051
|
-
Verification failed
|
|
1052
|
-
</p>
|
|
1053
|
-
<div style={errorStyle}>{error}</div>
|
|
1054
|
-
</>
|
|
1055
|
-
)}
|
|
1056
|
-
</div>
|
|
1057
|
-
|
|
1058
|
-
{isAutoTransitioning ? null : (
|
|
1059
|
-
<div
|
|
1060
|
-
style={{
|
|
1061
|
-
...styles.bottomSection,
|
|
1062
|
-
...responsiveStyles.bottomSection,
|
|
1063
|
-
...componentTextStyle,
|
|
1064
|
-
paddingTop: "12px",
|
|
1065
|
-
display: "flex",
|
|
1066
|
-
gap: "12px",
|
|
1067
|
-
justifyContent: "center",
|
|
1068
|
-
}}
|
|
1069
|
-
>
|
|
1070
|
-
{isVerificationComplete && !error ? (
|
|
1071
|
-
<button
|
|
1072
|
-
style={{
|
|
1073
|
-
...styles.button,
|
|
1074
|
-
...responsiveStyles.button,
|
|
1075
|
-
...acceptButtonStyle,
|
|
1076
|
-
}}
|
|
1077
|
-
onClick={onContinue}
|
|
1078
|
-
type="button"
|
|
1079
|
-
aria-label="Continue to consent form"
|
|
1080
|
-
>
|
|
1081
|
-
Continue
|
|
1082
|
-
</button>
|
|
1083
|
-
) : error && canRetry ? (
|
|
1084
|
-
<button
|
|
1085
|
-
style={{
|
|
1086
|
-
...styles.button,
|
|
1087
|
-
...responsiveStyles.button,
|
|
1088
|
-
...styles.acceptButton,
|
|
1089
|
-
...acceptButtonStyle,
|
|
1090
|
-
}}
|
|
1091
|
-
onClick={() => onBack(errorCode === "GUARDIAN_UNDER_18")}
|
|
1092
|
-
type="button"
|
|
1093
|
-
aria-label={
|
|
1094
|
-
errorCode === "GUARDIAN_UNDER_18"
|
|
1095
|
-
? "Change guardian details"
|
|
1096
|
-
: "Try verification again"
|
|
1097
|
-
}
|
|
1098
|
-
>
|
|
1099
|
-
{errorCode === "GUARDIAN_UNDER_18"
|
|
1100
|
-
? "Change Guardian"
|
|
1101
|
-
: "Try Again"}
|
|
1102
|
-
</button>
|
|
1103
|
-
) : error ? (
|
|
1104
|
-
<button
|
|
1105
|
-
style={{
|
|
1106
|
-
...styles.button,
|
|
1107
|
-
...responsiveStyles.button,
|
|
1108
|
-
...styles.cancelButton,
|
|
1109
|
-
...declineButtonStyle,
|
|
1110
|
-
}}
|
|
1111
|
-
onClick={() => onBack(false)}
|
|
1112
|
-
type="button"
|
|
1113
|
-
aria-label="Go back"
|
|
1114
|
-
>
|
|
1115
|
-
Go Back
|
|
1116
|
-
</button>
|
|
1117
|
-
) : (
|
|
1118
|
-
<button
|
|
1119
|
-
style={{
|
|
1120
|
-
...styles.button,
|
|
1121
|
-
...responsiveStyles.button,
|
|
1122
|
-
...styles.cancelButton,
|
|
1123
|
-
...declineButtonStyle,
|
|
1124
|
-
}}
|
|
1125
|
-
onClick={() => onBack(false)}
|
|
1126
|
-
type="button"
|
|
1127
|
-
aria-label="Cancel verification"
|
|
1128
|
-
>
|
|
1129
|
-
Cancel
|
|
1130
|
-
</button>
|
|
1131
|
-
)}
|
|
1132
|
-
</div>
|
|
1133
|
-
)}
|
|
1134
|
-
</>
|
|
1135
|
-
);
|
|
1136
|
-
};
|
|
1137
|
-
|
|
1138
|
-
/**
|
|
1139
|
-
* AgeVerification Component
|
|
1140
|
-
*
|
|
1141
|
-
* Displays an age verification screen asking if the user is 18+.
|
|
1142
|
-
*/
|
|
1143
|
-
const AgeVerification: React.FC<{
|
|
1144
|
-
onYes: () => void;
|
|
1145
|
-
onNo: () => void;
|
|
1146
|
-
onClose: () => void;
|
|
1147
|
-
styles: any;
|
|
1148
|
-
responsiveStyles: any;
|
|
1149
|
-
componentHeadingStyle: any;
|
|
1150
|
-
componentTextStyle: any;
|
|
1151
|
-
acceptButtonStyle: any;
|
|
1152
|
-
declineButtonStyle: any;
|
|
1153
|
-
logoUrl: string;
|
|
1154
|
-
isMobile: boolean;
|
|
1155
|
-
}> = ({
|
|
1156
|
-
onYes,
|
|
1157
|
-
onNo,
|
|
1158
|
-
onClose,
|
|
1159
|
-
styles,
|
|
1160
|
-
responsiveStyles,
|
|
1161
|
-
componentHeadingStyle,
|
|
1162
|
-
componentTextStyle,
|
|
1163
|
-
acceptButtonStyle,
|
|
1164
|
-
declineButtonStyle,
|
|
1165
|
-
logoUrl,
|
|
1166
|
-
isMobile,
|
|
1167
|
-
}) => {
|
|
1168
|
-
return (
|
|
1169
|
-
<>
|
|
1170
|
-
<div style={{ ...styles.topSection, ...componentTextStyle }}>
|
|
1171
|
-
<div style={styles.topLeft}>
|
|
1172
|
-
<img style={styles.logo} src={logoUrl} alt="Redacto Logo" />
|
|
1173
|
-
<h2
|
|
1174
|
-
id="age-verification-title"
|
|
1175
|
-
style={{
|
|
1176
|
-
...styles.title,
|
|
1177
|
-
...responsiveStyles.title,
|
|
1178
|
-
...componentHeadingStyle,
|
|
1179
|
-
}}
|
|
1180
|
-
>
|
|
1181
|
-
Age Verification Required
|
|
1182
|
-
</h2>
|
|
1183
|
-
</div>
|
|
1184
|
-
<button
|
|
1185
|
-
onClick={onClose}
|
|
1186
|
-
style={{
|
|
1187
|
-
background: "none",
|
|
1188
|
-
border: "none",
|
|
1189
|
-
color: componentHeadingStyle?.color || "#323B4B",
|
|
1190
|
-
cursor: "pointer",
|
|
1191
|
-
padding: isMobile ? "2px" : "4px",
|
|
1192
|
-
display: "flex",
|
|
1193
|
-
alignItems: "center",
|
|
1194
|
-
justifyContent: "center",
|
|
1195
|
-
}}
|
|
1196
|
-
aria-label="Close modal"
|
|
1197
|
-
>
|
|
1198
|
-
<svg
|
|
1199
|
-
width={isMobile ? "18" : "20"}
|
|
1200
|
-
height={isMobile ? "18" : "20"}
|
|
1201
|
-
viewBox="0 0 20 20"
|
|
1202
|
-
fill="none"
|
|
1203
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
1204
|
-
aria-hidden="true"
|
|
1205
|
-
>
|
|
1206
|
-
<path
|
|
1207
|
-
d="M15 5L5 15M5 5L15 15"
|
|
1208
|
-
stroke="currentColor"
|
|
1209
|
-
strokeWidth="2"
|
|
1210
|
-
strokeLinecap="round"
|
|
1211
|
-
strokeLinejoin="round"
|
|
1212
|
-
/>
|
|
1213
|
-
</svg>
|
|
1214
|
-
</button>
|
|
1215
|
-
</div>
|
|
1216
|
-
|
|
1217
|
-
<div
|
|
1218
|
-
style={{
|
|
1219
|
-
...styles.middleSection,
|
|
1220
|
-
...responsiveStyles.middleSection,
|
|
1221
|
-
...componentTextStyle,
|
|
1222
|
-
}}
|
|
1223
|
-
>
|
|
1224
|
-
<p
|
|
1225
|
-
style={{
|
|
1226
|
-
...styles.privacyText,
|
|
1227
|
-
...responsiveStyles.privacyText,
|
|
1228
|
-
...componentTextStyle,
|
|
1229
|
-
marginBottom: "16px",
|
|
1230
|
-
}}
|
|
1231
|
-
>
|
|
1232
|
-
To proceed with this consent form, we need to verify your age.
|
|
1233
|
-
</p>
|
|
1234
|
-
|
|
1235
|
-
<p
|
|
1236
|
-
style={{
|
|
1237
|
-
...styles.subTitle,
|
|
1238
|
-
...componentTextStyle,
|
|
1239
|
-
marginBottom: "24px",
|
|
1240
|
-
textAlign: "left",
|
|
1241
|
-
}}
|
|
1242
|
-
>
|
|
1243
|
-
Are you 18 years of age or older?
|
|
1244
|
-
</p>
|
|
1245
|
-
</div>
|
|
1246
|
-
|
|
1247
|
-
<div
|
|
1248
|
-
style={{
|
|
1249
|
-
...styles.bottomSection,
|
|
1250
|
-
...responsiveStyles.bottomSection,
|
|
1251
|
-
...componentTextStyle,
|
|
1252
|
-
}}
|
|
1253
|
-
>
|
|
1254
|
-
<button
|
|
1255
|
-
style={{
|
|
1256
|
-
...styles.button,
|
|
1257
|
-
...responsiveStyles.button,
|
|
1258
|
-
...styles.acceptButton,
|
|
1259
|
-
...acceptButtonStyle,
|
|
1260
|
-
}}
|
|
1261
|
-
onClick={onYes}
|
|
1262
|
-
type="button"
|
|
1263
|
-
aria-label="Confirm that I am 18 years of age or older"
|
|
1264
|
-
>
|
|
1265
|
-
Yes, I am 18 or older
|
|
1266
|
-
</button>
|
|
1267
|
-
<button
|
|
1268
|
-
style={{
|
|
1269
|
-
...styles.button,
|
|
1270
|
-
...responsiveStyles.button,
|
|
1271
|
-
...styles.cancelButton,
|
|
1272
|
-
...declineButtonStyle,
|
|
1273
|
-
}}
|
|
1274
|
-
onClick={onNo}
|
|
1275
|
-
type="button"
|
|
1276
|
-
aria-label="Indicate that I am under 18 years of age"
|
|
1277
|
-
>
|
|
1278
|
-
No, I am under 18
|
|
1279
|
-
</button>
|
|
1280
|
-
</div>
|
|
1281
|
-
</>
|
|
1282
|
-
);
|
|
1283
|
-
};
|
|
1284
|
-
|
|
1285
|
-
// =============================================================================
|
|
1286
|
-
// MAIN COMPONENT
|
|
1287
|
-
// =============================================================================
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* RedactoNoticeConsent
|
|
1291
|
-
*
|
|
1292
|
-
* Main consent management component that provides a comprehensive UI for users
|
|
1293
|
-
* to view and manage their consent preferences. Supports both initial consent
|
|
1294
|
-
* collection and reconsent flows.
|
|
1295
|
-
*/
|
|
1296
|
-
export const RedactoNoticeConsent = ({
|
|
1297
|
-
noticeId,
|
|
1298
|
-
accessToken,
|
|
1299
|
-
refreshToken,
|
|
1300
|
-
token,
|
|
1301
|
-
email,
|
|
1302
|
-
mobile,
|
|
1303
|
-
ucic,
|
|
1304
|
-
organisationUuid,
|
|
1305
|
-
workspaceUuid,
|
|
1306
|
-
baseUrl,
|
|
1307
|
-
ledgerBaseUrl,
|
|
1308
|
-
language = "en",
|
|
1309
|
-
blockUI = true,
|
|
1310
|
-
onAccept,
|
|
1311
|
-
onDecline,
|
|
1312
|
-
onError,
|
|
1313
|
-
settings,
|
|
1314
|
-
applicationId,
|
|
1315
|
-
validateAgainst = "all",
|
|
1316
|
-
digilockerMode = "redirect", // Default to full page redirect (DigiLocker blocks iframe embedding)
|
|
1317
|
-
digilockerCallbackUrl = "/digilocker-callback", // Default callback path
|
|
1318
|
-
includeFullyConsentedData = false,
|
|
1319
|
-
reviewModeButtonText = "Continue",
|
|
1320
|
-
}: Props) => {
|
|
1321
|
-
// =============================================================================
|
|
1322
|
-
// SANDBOX MODE
|
|
1323
|
-
// =============================================================================
|
|
1324
|
-
|
|
1325
|
-
// Sandbox PoC: when `token` is set, every API call bypasses the JWT and sends
|
|
1326
|
-
// the single X-Consent-Token header, carrying the acting identity (subject) in
|
|
1327
|
-
// each request's own payload. Org/workspace/subject are taken straight from
|
|
1328
|
-
// props. Passed through to the API layer alongside `accessToken`.
|
|
1329
|
-
const sandboxParams = useMemo(
|
|
1330
|
-
() => ({
|
|
1331
|
-
token,
|
|
1332
|
-
sandboxUcic: ucic,
|
|
1333
|
-
sandboxSubject: email || mobile,
|
|
1334
|
-
organisationUuid,
|
|
1335
|
-
workspaceUuid,
|
|
1336
|
-
}),
|
|
1337
|
-
[token, ucic, email, mobile, organisationUuid, workspaceUuid]
|
|
1338
|
-
);
|
|
1339
|
-
|
|
1340
|
-
// =============================================================================
|
|
1341
|
-
// PROP VALIDATION
|
|
1342
|
-
// =============================================================================
|
|
1343
|
-
|
|
1344
|
-
// Validate required props gracefully
|
|
1345
|
-
const propValidationError = useMemo(() => {
|
|
1346
|
-
if (!noticeId?.trim()) {
|
|
1347
|
-
return "RedactoNoticeConsent: 'noticeId' prop is required and cannot be empty";
|
|
1348
|
-
}
|
|
1349
|
-
// Sandbox mode: require a static token, org/workspace, and a subject instead
|
|
1350
|
-
// of a JWT access token.
|
|
1351
|
-
if (token?.trim()) {
|
|
1352
|
-
if (!organisationUuid?.trim() || !workspaceUuid?.trim()) {
|
|
1353
|
-
return "RedactoNoticeConsent: sandbox mode requires 'organisationUuid' and 'workspaceUuid'";
|
|
1354
|
-
}
|
|
1355
|
-
if (!email?.trim() && !mobile?.trim() && !ucic?.trim()) {
|
|
1356
|
-
return "RedactoNoticeConsent: sandbox mode requires 'email', 'mobile', or 'ucic'";
|
|
1357
|
-
}
|
|
1358
|
-
return null;
|
|
1359
|
-
}
|
|
1360
|
-
if (!accessToken?.trim()) {
|
|
1361
|
-
return "RedactoNoticeConsent: 'accessToken' prop is required and cannot be empty";
|
|
1362
|
-
}
|
|
1363
|
-
return null;
|
|
1364
|
-
}, [
|
|
1365
|
-
noticeId,
|
|
1366
|
-
accessToken,
|
|
1367
|
-
token,
|
|
1368
|
-
organisationUuid,
|
|
1369
|
-
workspaceUuid,
|
|
1370
|
-
email,
|
|
1371
|
-
mobile,
|
|
1372
|
-
ucic,
|
|
1373
|
-
]);
|
|
1374
|
-
|
|
1375
|
-
// Show error UI if props are invalid
|
|
1376
|
-
if (propValidationError) {
|
|
1377
|
-
return (
|
|
1378
|
-
<div
|
|
1379
|
-
style={{
|
|
1380
|
-
padding: "20px",
|
|
1381
|
-
backgroundColor: "#fee",
|
|
1382
|
-
border: "1px solid #fcc",
|
|
1383
|
-
borderRadius: "4px",
|
|
1384
|
-
color: "#c33",
|
|
1385
|
-
}}
|
|
1386
|
-
>
|
|
1387
|
-
<h3>Configuration Error</h3>
|
|
1388
|
-
<p>{propValidationError}</p>
|
|
1389
|
-
<p>Please check your component props and try again.</p>
|
|
1390
|
-
</div>
|
|
1391
|
-
);
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
// =============================================================================
|
|
1395
|
-
// STATE MANAGEMENT
|
|
1396
|
-
// =============================================================================
|
|
1397
|
-
|
|
1398
|
-
// Loading and submission states
|
|
1399
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
1400
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
1401
|
-
const [isSubmittingAccept, setIsSubmittingAccept] = useState(false);
|
|
1402
|
-
const [isSubmittingDecline, setIsSubmittingDecline] = useState(false);
|
|
1403
|
-
|
|
1404
|
-
// Content and consent data
|
|
1405
|
-
const [content, setContent] = useState<
|
|
1406
|
-
ConsentContent["detail"]["active_config"] | null
|
|
1407
|
-
>(null);
|
|
1408
|
-
const [categorizedPurposes, setCategorizedPurposes] = useState<{
|
|
1409
|
-
alreadyConsented: Array<PurposeItemData>;
|
|
1410
|
-
needsConsent: Array<PurposeItemData>;
|
|
1411
|
-
} | null>(null);
|
|
1412
|
-
|
|
1413
|
-
// User interaction states
|
|
1414
|
-
const [selectedPurposes, setSelectedPurposes] = useState<
|
|
1415
|
-
Record<string, boolean>
|
|
1416
|
-
>({});
|
|
1417
|
-
const [selectedDataElements, setSelectedDataElements] = useState<
|
|
1418
|
-
Record<string, boolean>
|
|
1419
|
-
>({});
|
|
1420
|
-
// Track initial data element selections for already-consented purposes
|
|
1421
|
-
const [initialDataElementSelections, setInitialDataElementSelections] =
|
|
1422
|
-
useState<Record<string, boolean>>({});
|
|
1423
|
-
const [collapsedPurposes, setCollapsedPurposes] = useState<
|
|
1424
|
-
Record<string, boolean>
|
|
1425
|
-
>({});
|
|
1426
|
-
const [hasAlreadyConsented, setHasAlreadyConsented] = useState(false);
|
|
1427
|
-
const [isFullyConsentedReviewMode, setIsFullyConsentedReviewMode] =
|
|
1428
|
-
useState(false);
|
|
1429
|
-
|
|
1430
|
-
// UI states
|
|
1431
|
-
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
|
|
1432
|
-
const [selectedLanguage, setSelectedLanguage] = useState<string>(language);
|
|
1433
|
-
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
1434
|
-
const [fetchError, setFetchError] = useState<Error | null>(null);
|
|
1435
|
-
|
|
1436
|
-
// TTS audio states
|
|
1437
|
-
const [ttsAudioUrls, setTtsAudioUrls] = useState<
|
|
1438
|
-
import("./api/types").TTSAudioUrlsResponse["detail"] | null
|
|
1439
|
-
>(null);
|
|
1440
|
-
const [isTTSAvailable, setIsTTSAvailable] = useState(false);
|
|
1441
|
-
const [isPlaying, setIsPlaying] = useState(false);
|
|
1442
|
-
const [isPaused, setIsPaused] = useState(false);
|
|
1443
|
-
const [currentAudioIndex, setCurrentAudioIndex] = useState(0);
|
|
1444
|
-
const [highlightedElementId, setHighlightedElementId] = useState<
|
|
1445
|
-
string | null
|
|
1446
|
-
>(null);
|
|
1447
|
-
|
|
1448
|
-
// Age verification states
|
|
1449
|
-
const [showAgeVerification, setShowAgeVerification] = useState(false);
|
|
1450
|
-
const [isMinorFlow, setIsMinorFlow] = useState(false);
|
|
1451
|
-
const [selfDeclaredAdult, setSelfDeclaredAdult] = useState(false);
|
|
1452
|
-
|
|
1453
|
-
// Guardian form states
|
|
1454
|
-
const [showGuardianForm, setShowGuardianForm] = useState(false);
|
|
1455
|
-
const [isSubmittingGuardian, setIsSubmittingGuardian] = useState(false);
|
|
1456
|
-
const [guardianFormData, setGuardianFormData] = useState({
|
|
1457
|
-
guardianName: "",
|
|
1458
|
-
guardianContact: "",
|
|
1459
|
-
guardianRelationship: "",
|
|
1460
|
-
});
|
|
1461
|
-
const [guardianFormErrors, setGuardianFormErrors] = useState<
|
|
1462
|
-
Record<string, string>
|
|
1463
|
-
>({});
|
|
1464
|
-
|
|
1465
|
-
// Guardian verification states
|
|
1466
|
-
const [showVerificationScreen, setShowVerificationScreen] = useState(false);
|
|
1467
|
-
const [verificationReference, setVerificationReference] = useState<string | null>(null);
|
|
1468
|
-
const [isInitiatingVerification, setIsInitiatingVerification] =
|
|
1469
|
-
useState(false);
|
|
1470
|
-
const [verificationError, setVerificationError] = useState<string | null>(
|
|
1471
|
-
null
|
|
1472
|
-
);
|
|
1473
|
-
const [verificationErrorCode, setVerificationErrorCode] = useState<string | null>(null);
|
|
1474
|
-
const [canRetryVerification, setCanRetryVerification] = useState(false);
|
|
1475
|
-
const [isPollingStatus, setIsPollingStatus] = useState(false);
|
|
1476
|
-
const [isVerificationComplete, setIsVerificationComplete] = useState(false);
|
|
1477
|
-
const [isAutoTransitioning, setIsAutoTransitioning] = useState(false);
|
|
1478
|
-
|
|
1479
|
-
// Refs and constants
|
|
1480
|
-
const modalRef = useRef<HTMLDivElement>(null);
|
|
1481
|
-
const firstFocusableRef = useRef<HTMLButtonElement>(null);
|
|
1482
|
-
const lastFocusableRef = useRef<HTMLButtonElement>(null);
|
|
1483
|
-
const abortControllerRef = useRef<AbortController | null>(null);
|
|
1484
|
-
const isRequestInProgressRef = useRef(false);
|
|
1485
|
-
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
1486
|
-
const popupCheckIntervalRef = useRef<number | null>(null);
|
|
1487
|
-
const statusPollIntervalRef = useRef<number | null>(null);
|
|
1488
|
-
const verificationPopupRef = useRef<Window | null>(null);
|
|
1489
|
-
const pollingCleanupRef = useRef<(() => void) | null>(null);
|
|
1490
|
-
const autoTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1491
|
-
const digilockerRedirectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1492
|
-
const isInitialMountRef = useRef(true);
|
|
1493
|
-
const isPollingStatusRef = useRef(false);
|
|
1494
|
-
const verificationSessionTokenRef = useRef<string | null>(null);
|
|
1495
|
-
|
|
1496
|
-
// =============================================================================
|
|
1497
|
-
// RESPONSIVE DESIGN & STYLING
|
|
1498
|
-
// =============================================================================
|
|
1499
|
-
|
|
1500
|
-
// Media query hooks
|
|
1501
|
-
const isMobile = useMediaQuery("(max-width: 768px)");
|
|
1502
|
-
|
|
1503
|
-
// Memoize responsive styles to avoid recalculation
|
|
1504
|
-
const responsiveStyles = useMemo(
|
|
1505
|
-
() => ({
|
|
1506
|
-
modal: {
|
|
1507
|
-
width: isMobile ? "90%" : "700px",
|
|
1508
|
-
},
|
|
1509
|
-
content: {
|
|
1510
|
-
margin: isMobile ? "20px" : "22px",
|
|
1511
|
-
},
|
|
1512
|
-
title: {
|
|
1513
|
-
fontSize: isMobile ? "16px" : "18px",
|
|
1514
|
-
},
|
|
1515
|
-
subTitle: {
|
|
1516
|
-
fontSize: isMobile ? "14px" : "16px",
|
|
1517
|
-
},
|
|
1518
|
-
optionTitle: {
|
|
1519
|
-
fontSize: isMobile ? "14px" : "16px",
|
|
1520
|
-
},
|
|
1521
|
-
optionDescription: {
|
|
1522
|
-
fontSize: isMobile ? "12px" : "14px",
|
|
1523
|
-
},
|
|
1524
|
-
dataElementText: {
|
|
1525
|
-
fontSize: isMobile ? "12px" : "14px",
|
|
1526
|
-
},
|
|
1527
|
-
bottomSection: {
|
|
1528
|
-
flexDirection: isMobile ? ("column" as const) : ("row" as const),
|
|
1529
|
-
gap: isMobile ? "12px" : "16px",
|
|
1530
|
-
},
|
|
1531
|
-
privacyText: {
|
|
1532
|
-
fontSize: isMobile ? "14px" : "16px",
|
|
1533
|
-
},
|
|
1534
|
-
errorDialog: {
|
|
1535
|
-
padding: isMobile ? "12px" : "16px",
|
|
1536
|
-
maxWidth: isMobile ? "100%" : "400px",
|
|
1537
|
-
marginBottom: isMobile ? "16px" : "20px",
|
|
1538
|
-
},
|
|
1539
|
-
errorTitle: {
|
|
1540
|
-
fontSize: isMobile ? "16px" : "18px",
|
|
1541
|
-
margin: isMobile ? "0 0 8px 0" : "0 0 12px 0",
|
|
1542
|
-
},
|
|
1543
|
-
errorMessage: {
|
|
1544
|
-
fontSize: isMobile ? "12px" : "14px",
|
|
1545
|
-
margin: isMobile ? "0 0 12px 0" : "0 0 16px 0",
|
|
1546
|
-
},
|
|
1547
|
-
errorButton: {
|
|
1548
|
-
padding: isMobile ? "6px 12px" : "8px 16px",
|
|
1549
|
-
fontSize: isMobile ? "12px" : "14px",
|
|
1550
|
-
},
|
|
1551
|
-
closeButton: {
|
|
1552
|
-
top: isMobile ? "4px" : "8px",
|
|
1553
|
-
right: isMobile ? "4px" : "8px",
|
|
1554
|
-
padding: isMobile ? "2px" : "4px",
|
|
1555
|
-
},
|
|
1556
|
-
topRight: {
|
|
1557
|
-
fontSize: isMobile ? "11px" : "12px",
|
|
1558
|
-
padding: isMobile ? "3px 6px" : "3px 9px",
|
|
1559
|
-
height: isMobile ? "auto" : undefined,
|
|
1560
|
-
width: isMobile ? "auto" : undefined,
|
|
1561
|
-
},
|
|
1562
|
-
topSection: {
|
|
1563
|
-
flexDirection: isMobile ? ("column" as const) : ("row" as const),
|
|
1564
|
-
alignItems: isMobile ? ("stretch" as const) : ("flex-start" as const),
|
|
1565
|
-
gap: isMobile ? "12px" : "10px",
|
|
1566
|
-
},
|
|
1567
|
-
logo: {
|
|
1568
|
-
maxWidth: isMobile ? "96px" : "140px",
|
|
1569
|
-
maxHeight: isMobile ? "28px" : "32px",
|
|
1570
|
-
},
|
|
1571
|
-
languageItem: {
|
|
1572
|
-
fontSize: isMobile ? "11px" : "12px",
|
|
1573
|
-
padding: isMobile ? "6px 10px" : "8px 12px",
|
|
1574
|
-
},
|
|
1575
|
-
middleSection: {
|
|
1576
|
-
paddingRight: isMobile ? "10px" : "15px",
|
|
1577
|
-
},
|
|
1578
|
-
button: {
|
|
1579
|
-
fontSize: isMobile ? "14px" : "16px",
|
|
1580
|
-
padding: isMobile ? "10px 20px" : "9px 45px",
|
|
1581
|
-
},
|
|
1582
|
-
}),
|
|
1583
|
-
[isMobile]
|
|
1584
|
-
);
|
|
1585
|
-
|
|
1586
|
-
// Memoize component-level styles
|
|
1587
|
-
const componentHeadingStyle = useMemo(
|
|
1588
|
-
() => ({
|
|
1589
|
-
color: settings?.headingColor || "#323B4B",
|
|
1590
|
-
fontFamily: settings?.font || "inherit",
|
|
1591
|
-
}),
|
|
1592
|
-
[settings?.headingColor, settings?.font]
|
|
1593
|
-
);
|
|
1594
|
-
|
|
1595
|
-
const componentTextStyle = useMemo(
|
|
1596
|
-
() => ({
|
|
1597
|
-
color: settings?.textColor || "#344054",
|
|
1598
|
-
fontFamily: settings?.font || "inherit",
|
|
1599
|
-
}),
|
|
1600
|
-
[settings?.textColor, settings?.font]
|
|
1601
|
-
);
|
|
1602
|
-
|
|
1603
|
-
// Memoize section style for buttons
|
|
1604
|
-
const sectionStyle = useMemo(
|
|
1605
|
-
() => ({
|
|
1606
|
-
color: settings?.textColor || "#344054",
|
|
1607
|
-
fontFamily: settings?.font || "inherit",
|
|
1608
|
-
}),
|
|
1609
|
-
[settings?.textColor, settings?.font]
|
|
1610
|
-
);
|
|
1611
|
-
|
|
1612
|
-
// =============================================================================
|
|
1613
|
-
// COMPUTED VALUES & BUSINESS LOGIC
|
|
1614
|
-
// =============================================================================
|
|
1615
|
-
|
|
1616
|
-
/**
|
|
1617
|
-
* Checks if all required data elements are selected for consent submission
|
|
1618
|
-
*/
|
|
1619
|
-
const areAllRequiredElementsChecked = useMemo(() => {
|
|
1620
|
-
if (!content) return false;
|
|
1621
|
-
|
|
1622
|
-
return content.purposes.every((purpose) => {
|
|
1623
|
-
const requiredElements = purpose.data_elements.filter(
|
|
1624
|
-
(element) => element.required
|
|
1625
|
-
);
|
|
1626
|
-
return requiredElements.every(
|
|
1627
|
-
(element) => selectedDataElements[`${purpose.uuid}-${element.uuid}`]
|
|
1628
|
-
);
|
|
1629
|
-
});
|
|
1630
|
-
}, [content, selectedDataElements]);
|
|
1631
|
-
|
|
1632
|
-
const acceptDisabled = isSubmitting || !areAllRequiredElementsChecked;
|
|
1633
|
-
|
|
1634
|
-
// =============================================================================
|
|
1635
|
-
// EFFECTS & LIFECYCLE
|
|
1636
|
-
// =============================================================================
|
|
1637
|
-
|
|
1638
|
-
/**
|
|
1639
|
-
* Reset component state when accessToken changes to prevent stale data
|
|
1640
|
-
*/
|
|
1641
|
-
useEffect(() => {
|
|
1642
|
-
// Clear API cache to prevent data from previous user sessions
|
|
1643
|
-
clearApiCache();
|
|
1644
|
-
|
|
1645
|
-
// Reset request flag when token changes
|
|
1646
|
-
isRequestInProgressRef.current = false;
|
|
1647
|
-
|
|
1648
|
-
// Clear component state when token changes
|
|
1649
|
-
setContent(null);
|
|
1650
|
-
setCategorizedPurposes(null);
|
|
1651
|
-
setSelectedPurposes({});
|
|
1652
|
-
setSelectedDataElements({});
|
|
1653
|
-
setInitialDataElementSelections({});
|
|
1654
|
-
setCollapsedPurposes({});
|
|
1655
|
-
setHasAlreadyConsented(false);
|
|
1656
|
-
setErrorMessage(null);
|
|
1657
|
-
setFetchError(null);
|
|
1658
|
-
setIsLoading(true);
|
|
1659
|
-
setIsSubmittingAccept(false);
|
|
1660
|
-
setIsSubmittingDecline(false);
|
|
1661
|
-
|
|
1662
|
-
// Clear age verification and guardian form state
|
|
1663
|
-
setShowAgeVerification(false);
|
|
1664
|
-
setIsMinorFlow(false);
|
|
1665
|
-
setSelfDeclaredAdult(false);
|
|
1666
|
-
setShowGuardianForm(false);
|
|
1667
|
-
setIsSubmittingGuardian(false);
|
|
1668
|
-
setGuardianFormData({
|
|
1669
|
-
guardianName: "",
|
|
1670
|
-
guardianContact: "",
|
|
1671
|
-
guardianRelationship: "",
|
|
1672
|
-
});
|
|
1673
|
-
setGuardianFormErrors({});
|
|
1674
|
-
|
|
1675
|
-
// Clear verification state
|
|
1676
|
-
setShowVerificationScreen(false);
|
|
1677
|
-
setVerificationReference(null);
|
|
1678
|
-
setIsInitiatingVerification(false);
|
|
1679
|
-
setVerificationError(null);
|
|
1680
|
-
setVerificationErrorCode(null);
|
|
1681
|
-
setCanRetryVerification(false);
|
|
1682
|
-
setIsVerificationComplete(false);
|
|
1683
|
-
setIsAutoTransitioning(false);
|
|
1684
|
-
setIsPollingStatus(false);
|
|
1685
|
-
isPollingStatusRef.current = false;
|
|
1686
|
-
verificationSessionTokenRef.current = null;
|
|
1687
|
-
if (popupCheckIntervalRef.current) {
|
|
1688
|
-
clearInterval(popupCheckIntervalRef.current);
|
|
1689
|
-
popupCheckIntervalRef.current = null;
|
|
1690
|
-
}
|
|
1691
|
-
if (statusPollIntervalRef.current) {
|
|
1692
|
-
clearTimeout(statusPollIntervalRef.current);
|
|
1693
|
-
statusPollIntervalRef.current = null;
|
|
1694
|
-
}
|
|
1695
|
-
// Clean up visibilitychange listener if polling was active
|
|
1696
|
-
pollingCleanupRef.current?.();
|
|
1697
|
-
pollingCleanupRef.current = null;
|
|
1698
|
-
verificationPopupRef.current?.close();
|
|
1699
|
-
verificationPopupRef.current = null;
|
|
1700
|
-
if (autoTransitionTimerRef.current) {
|
|
1701
|
-
clearTimeout(autoTransitionTimerRef.current);
|
|
1702
|
-
autoTransitionTimerRef.current = null;
|
|
1703
|
-
}
|
|
1704
|
-
if (digilockerRedirectTimeoutRef.current) {
|
|
1705
|
-
clearTimeout(digilockerRedirectTimeoutRef.current);
|
|
1706
|
-
digilockerRedirectTimeoutRef.current = null;
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
// Clear persisted state only on token change, not initial mount.
|
|
1710
|
-
// The restore effect (below) needs to read persisted state on first mount.
|
|
1711
|
-
if (!isInitialMountRef.current) {
|
|
1712
|
-
clearConsentState();
|
|
1713
|
-
}
|
|
1714
|
-
isInitialMountRef.current = false;
|
|
1715
|
-
}, [accessToken]);
|
|
1716
|
-
|
|
1717
|
-
/**
|
|
1718
|
-
* Handle automatic acceptance if user has already consented
|
|
1719
|
-
*/
|
|
1720
|
-
useEffect(() => {
|
|
1721
|
-
if (hasAlreadyConsented) {
|
|
1722
|
-
onAccept?.();
|
|
1723
|
-
}
|
|
1724
|
-
}, [hasAlreadyConsented, onAccept]);
|
|
1725
|
-
|
|
1726
|
-
const isDefaultLanguage = (lang: string): boolean => {
|
|
1727
|
-
if (!content) return true;
|
|
1728
|
-
if (lang === "English" || lang === "en" || lang === "EN") return true;
|
|
1729
|
-
return false;
|
|
1730
|
-
};
|
|
1731
|
-
|
|
1732
|
-
const getTranslatedText = (
|
|
1733
|
-
key: string,
|
|
1734
|
-
defaultText: string,
|
|
1735
|
-
itemId?: string
|
|
1736
|
-
): string => {
|
|
1737
|
-
if (!content) return defaultText;
|
|
1738
|
-
|
|
1739
|
-
// If default language is selected, use the base content from active_config
|
|
1740
|
-
if (isDefaultLanguage(selectedLanguage)) {
|
|
1741
|
-
// Check for specific keys in content object
|
|
1742
|
-
if (
|
|
1743
|
-
key === "privacy_policy_anchor_text" &&
|
|
1744
|
-
content.privacy_policy_anchor_text
|
|
1745
|
-
) {
|
|
1746
|
-
return content.privacy_policy_anchor_text;
|
|
1747
|
-
}
|
|
1748
|
-
if (
|
|
1749
|
-
key === "purpose_section_heading" &&
|
|
1750
|
-
content.purpose_section_heading
|
|
1751
|
-
) {
|
|
1752
|
-
return content.purpose_section_heading;
|
|
1753
|
-
}
|
|
1754
|
-
if (key === "notice_banner_heading" && content.notice_banner_heading) {
|
|
1755
|
-
return content.notice_banner_heading;
|
|
1756
|
-
}
|
|
1757
|
-
if (
|
|
1758
|
-
key === "privacy_policy_prefix_text" &&
|
|
1759
|
-
content.privacy_policy_prefix_text
|
|
1760
|
-
) {
|
|
1761
|
-
return content.privacy_policy_prefix_text;
|
|
1762
|
-
}
|
|
1763
|
-
if (key === "notice_text" && content.notice_text) {
|
|
1764
|
-
return content.notice_text;
|
|
1765
|
-
}
|
|
1766
|
-
return defaultText;
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
// For other languages, check supported_languages_and_translations
|
|
1770
|
-
if (!content.supported_languages_and_translations) {
|
|
1771
|
-
return defaultText;
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
const translationMap = content.supported_languages_and_translations[
|
|
1775
|
-
selectedLanguage
|
|
1776
|
-
] as TranslationObject | undefined;
|
|
1777
|
-
|
|
1778
|
-
if (!translationMap) {
|
|
1779
|
-
return defaultText;
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
if (itemId) {
|
|
1783
|
-
if (key === "purposes.name" || key === "purposes.description") {
|
|
1784
|
-
const purposeData = translationMap.purposes?.[itemId];
|
|
1785
|
-
if (purposeData) {
|
|
1786
|
-
if (typeof purposeData === "string") {
|
|
1787
|
-
return key === "purposes.name" ? purposeData : defaultText;
|
|
1788
|
-
}
|
|
1789
|
-
return key === "purposes.name"
|
|
1790
|
-
? purposeData.name || defaultText
|
|
1791
|
-
: purposeData.description || defaultText;
|
|
1792
|
-
}
|
|
1793
|
-
return defaultText;
|
|
1794
|
-
}
|
|
1795
|
-
if (key.startsWith("data_elements.")) {
|
|
1796
|
-
return translationMap.data_elements?.[itemId] || defaultText;
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
const value = translationMap[key];
|
|
1801
|
-
if (typeof value === "string" && value) {
|
|
1802
|
-
return value;
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
return defaultText;
|
|
1806
|
-
};
|
|
1807
|
-
|
|
1808
|
-
const getTranslatedDpoText = (
|
|
1809
|
-
key: string,
|
|
1810
|
-
defaultText: string
|
|
1811
|
-
): string => {
|
|
1812
|
-
if (!content?.dpo_info) return defaultText;
|
|
1813
|
-
|
|
1814
|
-
if (isDefaultLanguage(selectedLanguage)) {
|
|
1815
|
-
return (content.dpo_info as Record<string, string>)[key] || defaultText;
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
if (!content.supported_languages_and_translations) return defaultText;
|
|
1819
|
-
|
|
1820
|
-
const translationMap = content.supported_languages_and_translations[
|
|
1821
|
-
selectedLanguage
|
|
1822
|
-
] as TranslationObject | undefined;
|
|
1823
|
-
|
|
1824
|
-
if (translationMap?.dpo_info) {
|
|
1825
|
-
const translated = (translationMap.dpo_info as Record<string, string | undefined>)[key];
|
|
1826
|
-
if (translated) return translated;
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
return (content.dpo_info as Record<string, string>)[key] || defaultText;
|
|
1830
|
-
};
|
|
1831
|
-
|
|
1832
|
-
// =============================================================================
|
|
1833
|
-
// EVENT HANDLERS
|
|
1834
|
-
// =============================================================================
|
|
1835
|
-
|
|
1836
|
-
/**
|
|
1837
|
-
* Fetches consent notice data from the API
|
|
1838
|
-
*/
|
|
1839
|
-
const fetchNotice = async () => {
|
|
1840
|
-
// Prevent rapid successive calls
|
|
1841
|
-
if (isRequestInProgressRef.current) {
|
|
1842
|
-
return;
|
|
1843
|
-
}
|
|
1844
|
-
isRequestInProgressRef.current = true;
|
|
1845
|
-
|
|
1846
|
-
// Cancel any existing request
|
|
1847
|
-
if (abortControllerRef.current) {
|
|
1848
|
-
abortControllerRef.current.abort();
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
// Create new abort controller for this request
|
|
1852
|
-
abortControllerRef.current = new AbortController();
|
|
1853
|
-
|
|
1854
|
-
setIsLoading(true);
|
|
1855
|
-
setFetchError(null);
|
|
1856
|
-
// Reset age verification and guardian form states when starting a new fetch
|
|
1857
|
-
setShowAgeVerification(false);
|
|
1858
|
-
setShowGuardianForm(false);
|
|
1859
|
-
try {
|
|
1860
|
-
const consentContentData = await fetchConsentContent({
|
|
1861
|
-
noticeId,
|
|
1862
|
-
accessToken,
|
|
1863
|
-
refreshToken,
|
|
1864
|
-
baseUrl,
|
|
1865
|
-
language,
|
|
1866
|
-
specific_uuid: applicationId,
|
|
1867
|
-
validate_against: validateAgainst,
|
|
1868
|
-
include_fully_consented_data: includeFullyConsentedData,
|
|
1869
|
-
signal: abortControllerRef.current?.signal,
|
|
1870
|
-
...sandboxParams,
|
|
1871
|
-
});
|
|
1872
|
-
const activeConfig = consentContentData.detail.active_config;
|
|
1873
|
-
setContent(activeConfig);
|
|
1874
|
-
if (activeConfig.default_language) {
|
|
1875
|
-
// Normalize "en" to "English" for consistency
|
|
1876
|
-
const defaultLang = activeConfig.default_language;
|
|
1877
|
-
setSelectedLanguage(
|
|
1878
|
-
defaultLang === "en" || defaultLang === "EN" ? "English" : defaultLang
|
|
1879
|
-
);
|
|
1880
|
-
}
|
|
1881
|
-
const initialCollapsedState = activeConfig.purposes.reduce(
|
|
1882
|
-
(acc, purpose) => ({
|
|
1883
|
-
...acc,
|
|
1884
|
-
[purpose.uuid]: true,
|
|
1885
|
-
}),
|
|
1886
|
-
{}
|
|
1887
|
-
);
|
|
1888
|
-
setCollapsedPurposes(initialCollapsedState);
|
|
1889
|
-
|
|
1890
|
-
// Initialize selected states - handle reconsent pre-population.
|
|
1891
|
-
// Approach A: overlay the prior-consent / reconsent state from the Go
|
|
1892
|
-
// ledger's check-consent. Consents live in the ledger post-cutover, so
|
|
1893
|
-
// Python's notice-validation overlay is empty; the notice text/config
|
|
1894
|
-
// still comes from Python (above). Falls back to the Python-provided
|
|
1895
|
-
// values when the ledger is unavailable, and is skipped for the
|
|
1896
|
-
// magic-link flow (specific_uuid), which pre-fills from a specific event.
|
|
1897
|
-
let isReconsentRequired = consentContentData.detail.reconsent_required;
|
|
1898
|
-
let purposeSelections = consentContentData.detail.purpose_selections;
|
|
1899
|
-
if (ledgerBaseUrl && !applicationId) {
|
|
1900
|
-
const ledgerStatus = await fetchNoticeConsentStatus({
|
|
1901
|
-
accessToken,
|
|
1902
|
-
ledgerBaseUrl,
|
|
1903
|
-
noticeUuid: noticeId,
|
|
1904
|
-
signal: abortControllerRef.current?.signal,
|
|
1905
|
-
...sandboxParams,
|
|
1906
|
-
});
|
|
1907
|
-
if (ledgerStatus) {
|
|
1908
|
-
const overlay = buildLedgerConsentOverlay(
|
|
1909
|
-
ledgerStatus,
|
|
1910
|
-
activeConfig.purposes
|
|
1911
|
-
);
|
|
1912
|
-
isReconsentRequired = overlay?.reconsent_required ?? false;
|
|
1913
|
-
purposeSelections = overlay?.purpose_selections;
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
// Categorize purposes for reconsent UI
|
|
1918
|
-
const newCategorizedPurposes = isReconsentRequired
|
|
1919
|
-
? {
|
|
1920
|
-
alreadyConsented: activeConfig.purposes.filter(
|
|
1921
|
-
(purpose) => {
|
|
1922
|
-
const selection = purposeSelections?.[purpose.uuid];
|
|
1923
|
-
return (
|
|
1924
|
-
selection &&
|
|
1925
|
-
(selection.status === "ACTIVE" ||
|
|
1926
|
-
selection.status === "EXPIRED") &&
|
|
1927
|
-
!selection.needs_reconsent
|
|
1928
|
-
);
|
|
1929
|
-
}
|
|
1930
|
-
),
|
|
1931
|
-
needsConsent: activeConfig.purposes.filter(
|
|
1932
|
-
(purpose) => {
|
|
1933
|
-
const selection = purposeSelections?.[purpose.uuid];
|
|
1934
|
-
return (
|
|
1935
|
-
!selection ||
|
|
1936
|
-
selection.status !== "ACTIVE" ||
|
|
1937
|
-
selection.needs_reconsent
|
|
1938
|
-
);
|
|
1939
|
-
}
|
|
1940
|
-
),
|
|
1941
|
-
}
|
|
1942
|
-
: null;
|
|
1943
|
-
|
|
1944
|
-
setCategorizedPurposes(newCategorizedPurposes);
|
|
1945
|
-
|
|
1946
|
-
const initialPurposeState: Record<string, boolean> =
|
|
1947
|
-
activeConfig.purposes.reduce(
|
|
1948
|
-
(acc: Record<string, boolean>, purpose) => {
|
|
1949
|
-
if (purposeSelections?.[purpose.uuid]) {
|
|
1950
|
-
// Pre-populate with existing selection (reconsent or magic link flow)
|
|
1951
|
-
acc[purpose.uuid] = purposeSelections[purpose.uuid].selected;
|
|
1952
|
-
} else {
|
|
1953
|
-
// Default to false for new consent
|
|
1954
|
-
acc[purpose.uuid] = false;
|
|
1955
|
-
}
|
|
1956
|
-
return acc;
|
|
1957
|
-
},
|
|
1958
|
-
{}
|
|
1959
|
-
);
|
|
1960
|
-
setSelectedPurposes(initialPurposeState);
|
|
1961
|
-
|
|
1962
|
-
const initialDataElementState: Record<string, boolean> =
|
|
1963
|
-
activeConfig.purposes.reduce(
|
|
1964
|
-
(acc: Record<string, boolean>, purpose) => {
|
|
1965
|
-
purpose.data_elements.forEach((element) => {
|
|
1966
|
-
const combinedId = `${purpose.uuid}-${element.uuid}`;
|
|
1967
|
-
if (
|
|
1968
|
-
purposeSelections?.[purpose.uuid]?.data_elements?.[element.uuid]
|
|
1969
|
-
) {
|
|
1970
|
-
// Pre-populate with existing selection (reconsent or magic link flow)
|
|
1971
|
-
acc[combinedId] =
|
|
1972
|
-
purposeSelections[purpose.uuid].data_elements[
|
|
1973
|
-
element.uuid
|
|
1974
|
-
].selected;
|
|
1975
|
-
} else {
|
|
1976
|
-
// Default to false for new consent (including required elements)
|
|
1977
|
-
acc[combinedId] = false;
|
|
1978
|
-
}
|
|
1979
|
-
});
|
|
1980
|
-
return acc;
|
|
1981
|
-
},
|
|
1982
|
-
{}
|
|
1983
|
-
);
|
|
1984
|
-
setSelectedDataElements(initialDataElementState);
|
|
1985
|
-
// Store initial selections for already-consented purposes to track modifications
|
|
1986
|
-
if (isReconsentRequired && newCategorizedPurposes) {
|
|
1987
|
-
setIsFullyConsentedReviewMode(false);
|
|
1988
|
-
const initialSelections: Record<string, boolean> = {};
|
|
1989
|
-
newCategorizedPurposes.alreadyConsented.forEach((purpose) => {
|
|
1990
|
-
purpose.data_elements.forEach((element) => {
|
|
1991
|
-
const combinedId = `${purpose.uuid}-${element.uuid}`;
|
|
1992
|
-
if (
|
|
1993
|
-
purposeSelections?.[purpose.uuid]?.data_elements?.[element.uuid]
|
|
1994
|
-
) {
|
|
1995
|
-
initialSelections[combinedId] =
|
|
1996
|
-
purposeSelections[purpose.uuid].data_elements[element.uuid]
|
|
1997
|
-
.selected;
|
|
1998
|
-
} else {
|
|
1999
|
-
initialSelections[combinedId] = false;
|
|
2000
|
-
}
|
|
2001
|
-
});
|
|
2002
|
-
});
|
|
2003
|
-
setInitialDataElementSelections(initialSelections);
|
|
2004
|
-
} else if (includeFullyConsentedData && purposeSelections) {
|
|
2005
|
-
// Fully consented review mode: all purposes show green ticks, need initialDataElementSelections
|
|
2006
|
-
const allPurposesSelected =
|
|
2007
|
-
activeConfig.purposes.length > 0 &&
|
|
2008
|
-
activeConfig.purposes.every(
|
|
2009
|
-
(p) => purposeSelections[p.uuid]?.selected === true
|
|
2010
|
-
);
|
|
2011
|
-
setIsFullyConsentedReviewMode(allPurposesSelected);
|
|
2012
|
-
if (allPurposesSelected) {
|
|
2013
|
-
const initialSelections: Record<string, boolean> = {};
|
|
2014
|
-
activeConfig.purposes.forEach(
|
|
2015
|
-
(purpose) => {
|
|
2016
|
-
purpose.data_elements.forEach((element) => {
|
|
2017
|
-
const combinedId = `${purpose.uuid}-${element.uuid}`;
|
|
2018
|
-
if (
|
|
2019
|
-
purposeSelections[purpose.uuid]?.data_elements?.[element.uuid]
|
|
2020
|
-
) {
|
|
2021
|
-
initialSelections[combinedId] =
|
|
2022
|
-
purposeSelections[purpose.uuid].data_elements[element.uuid]
|
|
2023
|
-
.selected;
|
|
2024
|
-
} else {
|
|
2025
|
-
initialSelections[combinedId] = false;
|
|
2026
|
-
}
|
|
2027
|
-
});
|
|
2028
|
-
}
|
|
2029
|
-
);
|
|
2030
|
-
setInitialDataElementSelections(initialSelections);
|
|
2031
|
-
} else {
|
|
2032
|
-
setIsFullyConsentedReviewMode(false);
|
|
2033
|
-
setInitialDataElementSelections({});
|
|
2034
|
-
}
|
|
2035
|
-
} else {
|
|
2036
|
-
setIsFullyConsentedReviewMode(false);
|
|
2037
|
-
setInitialDataElementSelections({});
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
// Check if age verification is required - set this after all content is loaded
|
|
2041
|
-
// but before setIsLoading(false) to ensure proper render order
|
|
2042
|
-
if (consentContentData.detail.is_minor) {
|
|
2043
|
-
setIsMinorFlow(true);
|
|
2044
|
-
// Only show age verification if not returning from a DigiLocker redirect
|
|
2045
|
-
// (persisted state means user is mid-verification flow — the state restoration
|
|
2046
|
-
// effect handles showing the correct screen)
|
|
2047
|
-
const persistedState = getConsentState();
|
|
2048
|
-
if (!persistedState?.sessionToken && !persistedState?.verificationReference) {
|
|
2049
|
-
setShowAgeVerification(true);
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
// Set loading to false after all state updates are complete
|
|
2054
|
-
setIsLoading(false);
|
|
2055
|
-
} catch (err: unknown) {
|
|
2056
|
-
// Don't treat AbortError as an actual error - it's expected when requests are cancelled
|
|
2057
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
2058
|
-
return; // Don't update state for cancelled requests
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
const error = err as Error & { status?: number };
|
|
2062
|
-
setFetchError(error);
|
|
2063
|
-
if (error.status === 409) {
|
|
2064
|
-
setHasAlreadyConsented(true);
|
|
2065
|
-
} else {
|
|
2066
|
-
onError?.(error);
|
|
2067
|
-
}
|
|
2068
|
-
// Ensure loading is set to false even on error
|
|
2069
|
-
setIsLoading(false);
|
|
2070
|
-
} finally {
|
|
2071
|
-
isRequestInProgressRef.current = false; // Reset request flag
|
|
2072
|
-
}
|
|
2073
|
-
};
|
|
2074
|
-
|
|
2075
|
-
useEffect(() => {
|
|
2076
|
-
// Inject checkbox styles when component mounts
|
|
2077
|
-
injectCheckboxStyles();
|
|
2078
|
-
fetchNotice();
|
|
2079
|
-
}, [
|
|
2080
|
-
noticeId,
|
|
2081
|
-
accessToken,
|
|
2082
|
-
refreshToken,
|
|
2083
|
-
sandboxParams,
|
|
2084
|
-
language,
|
|
2085
|
-
applicationId,
|
|
2086
|
-
validateAgainst,
|
|
2087
|
-
includeFullyConsentedData,
|
|
2088
|
-
// Removed onError and isRefreshingToken from dependencies to prevent duplicate API calls
|
|
2089
|
-
// onError is only called on errors, not needed for initial data fetching
|
|
2090
|
-
]);
|
|
2091
|
-
|
|
2092
|
-
const togglePurposeCollapse = (purposeUuid: string) => {
|
|
2093
|
-
setCollapsedPurposes((prev) => ({
|
|
2094
|
-
...prev,
|
|
2095
|
-
[purposeUuid]: !prev[purposeUuid],
|
|
2096
|
-
}));
|
|
2097
|
-
};
|
|
2098
|
-
|
|
2099
|
-
const toggleLanguageDropdown = () => {
|
|
2100
|
-
setIsLanguageDropdownOpen(!isLanguageDropdownOpen);
|
|
2101
|
-
};
|
|
2102
|
-
|
|
2103
|
-
const handleLanguageSelect = (lang: string) => {
|
|
2104
|
-
setSelectedLanguage(lang);
|
|
2105
|
-
setIsLanguageDropdownOpen(false);
|
|
2106
|
-
};
|
|
2107
|
-
|
|
2108
|
-
const handlePurposeCheckboxChange = (purposeUuid: string) => {
|
|
2109
|
-
if (content) {
|
|
2110
|
-
const purpose = content.purposes.find((p) => p.uuid === purposeUuid);
|
|
2111
|
-
if (purpose) {
|
|
2112
|
-
const newPurposeState = !selectedPurposes[purposeUuid];
|
|
2113
|
-
|
|
2114
|
-
// Update purpose state
|
|
2115
|
-
setSelectedPurposes((prev) => ({
|
|
2116
|
-
...prev,
|
|
2117
|
-
[purposeUuid]: newPurposeState,
|
|
2118
|
-
}));
|
|
2119
|
-
|
|
2120
|
-
// Update all data elements for this purpose
|
|
2121
|
-
setSelectedDataElements((prev) => {
|
|
2122
|
-
const updated = { ...prev };
|
|
2123
|
-
purpose.data_elements.forEach((el) => {
|
|
2124
|
-
updated[`${purposeUuid}-${el.uuid}`] = newPurposeState;
|
|
2125
|
-
});
|
|
2126
|
-
return updated;
|
|
2127
|
-
});
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
};
|
|
2131
|
-
|
|
2132
|
-
const handleDataElementCheckboxChange = (
|
|
2133
|
-
elementUuid: string,
|
|
2134
|
-
purposeUuid: string
|
|
2135
|
-
) => {
|
|
2136
|
-
// Prevent state updates if content is not loaded yet
|
|
2137
|
-
if (!content) return;
|
|
2138
|
-
|
|
2139
|
-
const combinedId = `${purposeUuid}-${elementUuid}`;
|
|
2140
|
-
|
|
2141
|
-
// Update data element state
|
|
2142
|
-
setSelectedDataElements((prev) => {
|
|
2143
|
-
const newState = {
|
|
2144
|
-
...prev,
|
|
2145
|
-
[combinedId]: !prev[combinedId],
|
|
2146
|
-
};
|
|
2147
|
-
|
|
2148
|
-
// Check if all required elements for this purpose are checked
|
|
2149
|
-
const purpose = content.purposes.find((p) => p.uuid === purposeUuid);
|
|
2150
|
-
if (purpose) {
|
|
2151
|
-
const requiredElements = purpose.data_elements.filter(
|
|
2152
|
-
(el) => el.required
|
|
2153
|
-
);
|
|
2154
|
-
|
|
2155
|
-
let shouldCheckPurpose: boolean;
|
|
2156
|
-
|
|
2157
|
-
if (requiredElements.length > 0) {
|
|
2158
|
-
// Has required elements: check purpose when all required are selected
|
|
2159
|
-
shouldCheckPurpose = requiredElements.every(
|
|
2160
|
-
(el) => newState[`${purposeUuid}-${el.uuid}`]
|
|
2161
|
-
);
|
|
2162
|
-
} else {
|
|
2163
|
-
// No required elements: check purpose when ANY element is selected
|
|
2164
|
-
shouldCheckPurpose = purpose.data_elements.some(
|
|
2165
|
-
(el) => newState[`${purposeUuid}-${el.uuid}`]
|
|
2166
|
-
);
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
setSelectedPurposes((prevPurposes) => ({
|
|
2170
|
-
...prevPurposes,
|
|
2171
|
-
[purposeUuid]: shouldCheckPurpose,
|
|
2172
|
-
}));
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
return newState;
|
|
2176
|
-
});
|
|
2177
|
-
};
|
|
2178
|
-
|
|
2179
|
-
const handleAccept = async () => {
|
|
2180
|
-
// Create abort controller for submission
|
|
2181
|
-
const submitController = new AbortController();
|
|
2182
|
-
|
|
2183
|
-
try {
|
|
2184
|
-
// Block submission if minor consent flow is active but verification reference is missing
|
|
2185
|
-
if (isMinorFlow && !verificationReference) {
|
|
2186
|
-
setErrorMessage("Guardian verification is required before consent can be submitted.");
|
|
2187
|
-
return;
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
setIsSubmittingAccept(true);
|
|
2191
|
-
setIsSubmitting(true); // Keep for backward compatibility with acceptDisabled
|
|
2192
|
-
setErrorMessage(null);
|
|
2193
|
-
if (content) {
|
|
2194
|
-
await submitConsentEvent({
|
|
2195
|
-
accessToken,
|
|
2196
|
-
baseUrl,
|
|
2197
|
-
ledgerBaseUrl,
|
|
2198
|
-
noticeUuid: content.notice_uuid,
|
|
2199
|
-
purposes: content.purposes.map((purpose) => {
|
|
2200
|
-
// Check if this is an already-consented purpose that was modified
|
|
2201
|
-
const isAlreadyConsented =
|
|
2202
|
-
categorizedPurposes?.alreadyConsented.some(
|
|
2203
|
-
(p) => p.uuid === purpose.uuid
|
|
2204
|
-
) || false;
|
|
2205
|
-
|
|
2206
|
-
let purposeSelected = selectedPurposes[purpose.uuid] || false;
|
|
2207
|
-
|
|
2208
|
-
// If it's an already-consented purpose, check if any data elements were modified
|
|
2209
|
-
if (isAlreadyConsented) {
|
|
2210
|
-
const wasModified = purpose.data_elements.some((element) => {
|
|
2211
|
-
const combinedId = `${purpose.uuid}-${element.uuid}`;
|
|
2212
|
-
const currentSelection =
|
|
2213
|
-
selectedDataElements[combinedId] || false;
|
|
2214
|
-
const initialSelection =
|
|
2215
|
-
initialDataElementSelections[combinedId] || false;
|
|
2216
|
-
return currentSelection !== initialSelection;
|
|
2217
|
-
});
|
|
2218
|
-
|
|
2219
|
-
// If modified, treat as new selection (mark purpose as selected)
|
|
2220
|
-
if (wasModified) {
|
|
2221
|
-
purposeSelected = true;
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
return {
|
|
2226
|
-
...purpose,
|
|
2227
|
-
selected: purposeSelected,
|
|
2228
|
-
data_elements: purpose.data_elements.map((element) => ({
|
|
2229
|
-
...element,
|
|
2230
|
-
selected: element.required
|
|
2231
|
-
? true
|
|
2232
|
-
: selectedDataElements[`${purpose.uuid}-${element.uuid}`] ||
|
|
2233
|
-
false,
|
|
2234
|
-
})),
|
|
2235
|
-
};
|
|
2236
|
-
}),
|
|
2237
|
-
declined: false,
|
|
2238
|
-
language: toBcp47Code(selectedLanguage),
|
|
2239
|
-
meta_data: applicationId
|
|
2240
|
-
? { specific_uuid: applicationId }
|
|
2241
|
-
: undefined,
|
|
2242
|
-
guardianVerificationReference: verificationReference || undefined,
|
|
2243
|
-
selfDeclaredAdult: selfDeclaredAdult || undefined,
|
|
2244
|
-
signal: submitController.signal,
|
|
2245
|
-
...sandboxParams,
|
|
2246
|
-
});
|
|
2247
|
-
}
|
|
2248
|
-
onAccept?.();
|
|
2249
|
-
} catch (error) {
|
|
2250
|
-
// Don't treat AbortError as an actual error
|
|
2251
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
2252
|
-
return;
|
|
2253
|
-
}
|
|
2254
|
-
|
|
2255
|
-
const err = error as Error;
|
|
2256
|
-
// The SDK is a passthrough: server-owned message wins. Only the
|
|
2257
|
-
// bare-Error fallback is used when no message is present (e.g. a
|
|
2258
|
-
// network failure that never produced a Response).
|
|
2259
|
-
const message =
|
|
2260
|
-
err.message || "Failed to submit consent. Please try again.";
|
|
2261
|
-
setErrorMessage(message);
|
|
2262
|
-
onError?.(error as Error);
|
|
2263
|
-
} finally {
|
|
2264
|
-
setIsSubmittingAccept(false);
|
|
2265
|
-
setIsSubmitting(false); // Keep for backward compatibility
|
|
2266
|
-
}
|
|
2267
|
-
};
|
|
2268
|
-
|
|
2269
|
-
const handleDecline = useCallback(async () => {
|
|
2270
|
-
try {
|
|
2271
|
-
setIsSubmittingDecline(true);
|
|
2272
|
-
setIsSubmitting(true);
|
|
2273
|
-
onDecline?.();
|
|
2274
|
-
} finally {
|
|
2275
|
-
setIsSubmittingDecline(false);
|
|
2276
|
-
setIsSubmitting(false);
|
|
2277
|
-
}
|
|
2278
|
-
}, [onDecline]);
|
|
2279
|
-
|
|
2280
|
-
// Guardian form handlers
|
|
2281
|
-
const handleGuardianFormChange = useCallback(
|
|
2282
|
-
(field: string, value: string) => {
|
|
2283
|
-
setGuardianFormData((prev) => ({
|
|
2284
|
-
...prev,
|
|
2285
|
-
[field]: value,
|
|
2286
|
-
}));
|
|
2287
|
-
// Clear error when user starts typing
|
|
2288
|
-
if (guardianFormErrors[field]) {
|
|
2289
|
-
setGuardianFormErrors((prev) => ({
|
|
2290
|
-
...prev,
|
|
2291
|
-
[field]: "",
|
|
2292
|
-
}));
|
|
2293
|
-
}
|
|
2294
|
-
},
|
|
2295
|
-
[guardianFormErrors]
|
|
2296
|
-
);
|
|
2297
|
-
|
|
2298
|
-
const validateGuardianForm = () => {
|
|
2299
|
-
const errors: Record<string, string> = {};
|
|
2300
|
-
|
|
2301
|
-
if (!guardianFormData.guardianName.trim()) {
|
|
2302
|
-
errors.guardianName = "Guardian name is required";
|
|
2303
|
-
}
|
|
2304
|
-
if (!guardianFormData.guardianContact.trim()) {
|
|
2305
|
-
errors.guardianContact = "Guardian contact is required";
|
|
2306
|
-
}
|
|
2307
|
-
if (!guardianFormData.guardianRelationship.trim()) {
|
|
2308
|
-
errors.guardianRelationship = "Relationship to guardian is required";
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
|
-
setGuardianFormErrors(errors);
|
|
2312
|
-
return Object.keys(errors).length === 0;
|
|
2313
|
-
};
|
|
2314
|
-
|
|
2315
|
-
const handleGuardianFormNext = useCallback(async () => {
|
|
2316
|
-
if (!validateGuardianForm()) {
|
|
2317
|
-
return;
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
// Create abort controller for submission
|
|
2321
|
-
const guardianController = new AbortController();
|
|
2322
|
-
|
|
2323
|
-
try {
|
|
2324
|
-
setIsSubmittingGuardian(true);
|
|
2325
|
-
setGuardianFormErrors({}); // Clear any previous errors
|
|
2326
|
-
setVerificationError(null);
|
|
2327
|
-
|
|
2328
|
-
// For redirect mode: Don't send frontendCallbackUrl
|
|
2329
|
-
// DigiLocker should redirect to BACKEND callback URL (e.g., http://localhost:8000/consent/public/digilocker/callback)
|
|
2330
|
-
// Backend processes the callback and updates status in database
|
|
2331
|
-
// SDK will poll verify-status endpoint to detect completion
|
|
2332
|
-
//
|
|
2333
|
-
// For popup mode: Send frontendCallbackUrl so backend can redirect back to frontend
|
|
2334
|
-
const callbackUrl = digilockerMode === "popup"
|
|
2335
|
-
? new URL(digilockerCallbackUrl, window.location.origin).href
|
|
2336
|
-
: undefined;
|
|
2337
|
-
|
|
2338
|
-
// Initiate guardian verification - combines guardian details + DigiLocker start
|
|
2339
|
-
const response = await initiateGuardianVerification({
|
|
2340
|
-
accessToken,
|
|
2341
|
-
baseUrl,
|
|
2342
|
-
guardianName: guardianFormData.guardianName,
|
|
2343
|
-
guardianContact: guardianFormData.guardianContact,
|
|
2344
|
-
guardianRelationship: guardianFormData.guardianRelationship,
|
|
2345
|
-
frontendCallbackUrl: callbackUrl, // Only for popup mode
|
|
2346
|
-
signal: guardianController.signal,
|
|
2347
|
-
});
|
|
2348
|
-
|
|
2349
|
-
setIsSubmittingGuardian(false);
|
|
2350
|
-
|
|
2351
|
-
// ── Already verified: skip DigiLocker, brief success flash then consent form ──
|
|
2352
|
-
if (response.already_verified && response.verification_reference) {
|
|
2353
|
-
setVerificationReference(response.verification_reference);
|
|
2354
|
-
setShowGuardianForm(false);
|
|
2355
|
-
setShowVerificationScreen(true);
|
|
2356
|
-
setIsInitiatingVerification(false);
|
|
2357
|
-
setIsVerificationComplete(true);
|
|
2358
|
-
setIsAutoTransitioning(true);
|
|
2359
|
-
|
|
2360
|
-
// Auto-transition to consent form after brief success flash
|
|
2361
|
-
autoTransitionTimerRef.current = setTimeout(() => {
|
|
2362
|
-
setShowVerificationScreen(false);
|
|
2363
|
-
setIsVerificationComplete(false);
|
|
2364
|
-
setIsAutoTransitioning(false);
|
|
2365
|
-
autoTransitionTimerRef.current = null;
|
|
2366
|
-
}, 1000);
|
|
2367
|
-
return;
|
|
2368
|
-
}
|
|
2369
|
-
|
|
2370
|
-
// ── New verification: needs DigiLocker ──
|
|
2371
|
-
if (!response.session_token || !response.digilocker_redirect_url) {
|
|
2372
|
-
throw new Error("Invalid response from server: missing required fields");
|
|
2373
|
-
}
|
|
2374
|
-
|
|
2375
|
-
// Show verification screen before redirect
|
|
2376
|
-
setShowGuardianForm(false);
|
|
2377
|
-
setShowVerificationScreen(true);
|
|
2378
|
-
setIsInitiatingVerification(true);
|
|
2379
|
-
|
|
2380
|
-
if (digilockerMode === "redirect") {
|
|
2381
|
-
// Save state BEFORE redirect — restored when user returns from DigiLocker callback
|
|
2382
|
-
// NOTE: Guardian PII (name, contact, relationship) intentionally excluded from
|
|
2383
|
-
// persisted state to avoid storing sensitive data in sessionStorage.
|
|
2384
|
-
saveConsentState({
|
|
2385
|
-
flowState: "digilocker-pending",
|
|
2386
|
-
sessionToken: response.session_token,
|
|
2387
|
-
guardianInfoUuid: response.guardian_info_uuid,
|
|
2388
|
-
selectedPurposes,
|
|
2389
|
-
selectedDataElements,
|
|
2390
|
-
timestamp: new Date().toISOString(),
|
|
2391
|
-
originalUrl: window.location.href,
|
|
2392
|
-
noticeId,
|
|
2393
|
-
baseUrl,
|
|
2394
|
-
});
|
|
2395
|
-
|
|
2396
|
-
// Open DigiLocker in a new tab — keeps original page open for polling
|
|
2397
|
-
// Flow: DigiLocker → backend callback → backend updates DB → this tab polls → detects completion
|
|
2398
|
-
// NOTE: Do NOT pass "noopener" in the features string (3rd param) — it causes
|
|
2399
|
-
// window.open to return null per spec, even when the tab opens successfully.
|
|
2400
|
-
// Instead, manually null out opener after opening.
|
|
2401
|
-
try {
|
|
2402
|
-
const openedWindow = window.open(
|
|
2403
|
-
response.digilocker_redirect_url,
|
|
2404
|
-
"_blank"
|
|
2405
|
-
);
|
|
2406
|
-
|
|
2407
|
-
if (!openedWindow) {
|
|
2408
|
-
// Popup was blocked — show error instead of same-tab redirect
|
|
2409
|
-
// (same-tab redirect would lose the current page with no frontend route back)
|
|
2410
|
-
console.warn("Popup blocked — cannot open DigiLocker in new tab");
|
|
2411
|
-
setIsInitiatingVerification(false);
|
|
2412
|
-
setVerificationError(
|
|
2413
|
-
"Unable to open DigiLocker in a new tab. Please allow popups for this site and try again."
|
|
2414
|
-
);
|
|
2415
|
-
return;
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
// Prevent the opened tab from accessing window.opener (security)
|
|
2419
|
-
try { openedWindow.opener = null; } catch { /* cross-origin — safe to ignore */ }
|
|
2420
|
-
} catch (error) {
|
|
2421
|
-
console.error("Failed to open DigiLocker in new tab:", error);
|
|
2422
|
-
setIsInitiatingVerification(false);
|
|
2423
|
-
setVerificationError(
|
|
2424
|
-
"Unable to open DigiLocker. Please allow popups for this site and try again."
|
|
2425
|
-
);
|
|
2426
|
-
return;
|
|
2427
|
-
}
|
|
2428
|
-
|
|
2429
|
-
setIsInitiatingVerification(false);
|
|
2430
|
-
setIsPollingStatus(true);
|
|
2431
|
-
isPollingStatusRef.current = true;
|
|
2432
|
-
startStatusPolling(response.session_token);
|
|
2433
|
-
} else {
|
|
2434
|
-
// Popup mode - open DigiLocker in new window
|
|
2435
|
-
setIsInitiatingVerification(true);
|
|
2436
|
-
|
|
2437
|
-
const popup = window.open(
|
|
2438
|
-
response.digilocker_redirect_url,
|
|
2439
|
-
"digilocker_verification",
|
|
2440
|
-
"width=600,height=700,scrollbars=yes,resizable=yes"
|
|
2441
|
-
);
|
|
2442
|
-
|
|
2443
|
-
if (popup) {
|
|
2444
|
-
verificationPopupRef.current = popup;
|
|
2445
|
-
startPopupMonitoring(popup);
|
|
2446
|
-
startStatusPolling(response.session_token);
|
|
2447
|
-
// Keep isInitiatingVerification true while polling
|
|
2448
|
-
} else {
|
|
2449
|
-
// Popup blocked - fall back to redirect
|
|
2450
|
-
setIsInitiatingVerification(false);
|
|
2451
|
-
setVerificationError(
|
|
2452
|
-
"Popup blocked. Please allow popups for this site or use redirect mode."
|
|
2453
|
-
);
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
} catch (error) {
|
|
2457
|
-
// Handle API errors
|
|
2458
|
-
const err = error as Error & { status?: number };
|
|
2459
|
-
setIsInitiatingVerification(false);
|
|
2460
|
-
|
|
2461
|
-
if (err.status === 422) {
|
|
2462
|
-
// Validation error from server - try to extract field-specific errors
|
|
2463
|
-
if (err.message.includes("guardian_contact")) {
|
|
2464
|
-
setGuardianFormErrors({ guardianContact: err.message });
|
|
2465
|
-
} else if (err.message.includes("guardian_name")) {
|
|
2466
|
-
setGuardianFormErrors({ guardianName: err.message });
|
|
2467
|
-
} else if (err.message.includes("guardian_relationship")) {
|
|
2468
|
-
setGuardianFormErrors({ guardianRelationship: err.message });
|
|
2469
|
-
} else {
|
|
2470
|
-
setGuardianFormErrors({ general: err.message });
|
|
2471
|
-
}
|
|
2472
|
-
// Go back to guardian form to show errors
|
|
2473
|
-
setShowVerificationScreen(false);
|
|
2474
|
-
setShowGuardianForm(true);
|
|
2475
|
-
} else {
|
|
2476
|
-
// Other errors - show general error
|
|
2477
|
-
setGuardianFormErrors({
|
|
2478
|
-
general:
|
|
2479
|
-
err.message ||
|
|
2480
|
-
"Failed to initiate verification. Please try again.",
|
|
2481
|
-
});
|
|
2482
|
-
setShowVerificationScreen(false);
|
|
2483
|
-
setShowGuardianForm(true);
|
|
2484
|
-
onError?.(err);
|
|
2485
|
-
}
|
|
2486
|
-
} finally {
|
|
2487
|
-
setIsSubmittingGuardian(false);
|
|
2488
|
-
}
|
|
2489
|
-
}, [accessToken, sandboxParams, baseUrl, guardianFormData, digilockerMode, digilockerCallbackUrl, selectedPurposes, selectedDataElements, noticeId, onError]);
|
|
2490
|
-
|
|
2491
|
-
// Age verification handlers
|
|
2492
|
-
const handleAgeVerificationYes = useCallback(() => {
|
|
2493
|
-
setShowAgeVerification(false);
|
|
2494
|
-
setIsMinorFlow(false);
|
|
2495
|
-
setSelfDeclaredAdult(true);
|
|
2496
|
-
// User is 18+, proceed directly to consent
|
|
2497
|
-
}, []);
|
|
2498
|
-
|
|
2499
|
-
const handleAgeVerificationNo = useCallback(() => {
|
|
2500
|
-
setShowAgeVerification(false);
|
|
2501
|
-
setShowGuardianForm(true);
|
|
2502
|
-
// User is under 18, show guardian form
|
|
2503
|
-
}, []);
|
|
2504
|
-
|
|
2505
|
-
const handleVerificationContinue = useCallback(() => {
|
|
2506
|
-
// Transition from verification success screen to consent form
|
|
2507
|
-
setShowVerificationScreen(false);
|
|
2508
|
-
setIsVerificationComplete(false);
|
|
2509
|
-
}, []);
|
|
2510
|
-
|
|
2511
|
-
// Guardian verification handlers
|
|
2512
|
-
const handlePopupClosed = useCallback(async () => {
|
|
2513
|
-
// Stop the popup monitor
|
|
2514
|
-
if (popupCheckIntervalRef.current) {
|
|
2515
|
-
clearInterval(popupCheckIntervalRef.current);
|
|
2516
|
-
popupCheckIntervalRef.current = null;
|
|
2517
|
-
}
|
|
2518
|
-
// Stop status polling timer
|
|
2519
|
-
if (statusPollIntervalRef.current) {
|
|
2520
|
-
clearTimeout(statusPollIntervalRef.current);
|
|
2521
|
-
statusPollIntervalRef.current = null;
|
|
2522
|
-
}
|
|
2523
|
-
// Clean up visibilitychange listener
|
|
2524
|
-
pollingCleanupRef.current?.();
|
|
2525
|
-
pollingCleanupRef.current = null;
|
|
2526
|
-
// Null out popup ref
|
|
2527
|
-
verificationPopupRef.current = null;
|
|
2528
|
-
|
|
2529
|
-
// Use ref to avoid stale closure — startPopupMonitoring installs interval
|
|
2530
|
-
// before startStatusPolling flips isPollingStatus state
|
|
2531
|
-
if (!isPollingStatusRef.current) return;
|
|
2532
|
-
|
|
2533
|
-
// Perform one final status check — the user may have completed verification
|
|
2534
|
-
// right before closing the popup
|
|
2535
|
-
const sessionToken = verificationSessionTokenRef.current;
|
|
2536
|
-
if (sessionToken) {
|
|
2537
|
-
try {
|
|
2538
|
-
const response = await verifyGuardianStatus({
|
|
2539
|
-
accessToken,
|
|
2540
|
-
baseUrl,
|
|
2541
|
-
sessionToken,
|
|
2542
|
-
});
|
|
2543
|
-
|
|
2544
|
-
if (response.status === "verified") {
|
|
2545
|
-
const ref = response.verification_reference || response.guardian_details?.verification_reference;
|
|
2546
|
-
if (ref) {
|
|
2547
|
-
setVerificationReference(ref);
|
|
2548
|
-
setIsVerificationComplete(true);
|
|
2549
|
-
setIsPollingStatus(false);
|
|
2550
|
-
isPollingStatusRef.current = false;
|
|
2551
|
-
setIsInitiatingVerification(false);
|
|
2552
|
-
clearConsentState();
|
|
2553
|
-
return;
|
|
2554
|
-
}
|
|
2555
|
-
}
|
|
2556
|
-
} catch {
|
|
2557
|
-
// Final check failed — fall through to show error
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
// Verification not complete — mark as failure
|
|
2562
|
-
setVerificationError(
|
|
2563
|
-
"Verification window was closed. Please try again."
|
|
2564
|
-
);
|
|
2565
|
-
setIsPollingStatus(false);
|
|
2566
|
-
isPollingStatusRef.current = false;
|
|
2567
|
-
setIsInitiatingVerification(false);
|
|
2568
|
-
}, [accessToken, sandboxParams, baseUrl]);
|
|
2569
|
-
|
|
2570
|
-
const startPopupMonitoring = useCallback((popup: Window) => {
|
|
2571
|
-
// Check every 500ms if popup is closed
|
|
2572
|
-
popupCheckIntervalRef.current = window.setInterval(() => {
|
|
2573
|
-
if (popup.closed) {
|
|
2574
|
-
handlePopupClosed();
|
|
2575
|
-
}
|
|
2576
|
-
}, 500);
|
|
2577
|
-
}, [handlePopupClosed]);
|
|
2578
|
-
|
|
2579
|
-
/** Map error_code to user-friendly message */
|
|
2580
|
-
const getVerificationErrorMessage = useCallback(
|
|
2581
|
-
(errorCode: string | null, fallbackError: string | null): string => {
|
|
2582
|
-
switch (errorCode) {
|
|
2583
|
-
case "GUARDIAN_UNDER_18":
|
|
2584
|
-
return "The guardian must be 18 years or older. Please provide details of a different guardian.";
|
|
2585
|
-
case "TOKEN_FAILED":
|
|
2586
|
-
return "DigiLocker verification could not be completed. Please try again.";
|
|
2587
|
-
case "DIGILOCKER_AUTH_FAILED":
|
|
2588
|
-
return "DigiLocker authorization was cancelled or failed. Please try again.";
|
|
2589
|
-
case "NO_AUTH_CODE":
|
|
2590
|
-
return "DigiLocker did not return an authorization code. Please try again.";
|
|
2591
|
-
case "SESSION_EXPIRED":
|
|
2592
|
-
return "Verification session has expired. Please try again.";
|
|
2593
|
-
default:
|
|
2594
|
-
return fallbackError || "Verification failed. Please try again.";
|
|
2595
|
-
}
|
|
2596
|
-
},
|
|
2597
|
-
[]
|
|
2598
|
-
);
|
|
2599
|
-
|
|
2600
|
-
/**
|
|
2601
|
-
* Poll verification status every 3s, hard stop at 2 minutes.
|
|
2602
|
-
* Pauses when tab is hidden (user is on DigiLocker tab), resumes on return.
|
|
2603
|
-
*/
|
|
2604
|
-
const startStatusPolling = useCallback(
|
|
2605
|
-
(token: string) => {
|
|
2606
|
-
verificationSessionTokenRef.current = token;
|
|
2607
|
-
setIsPollingStatus(true);
|
|
2608
|
-
isPollingStatusRef.current = true;
|
|
2609
|
-
setVerificationError(null);
|
|
2610
|
-
setVerificationErrorCode(null);
|
|
2611
|
-
setCanRetryVerification(false);
|
|
2612
|
-
|
|
2613
|
-
let attempts = 0;
|
|
2614
|
-
let consecutiveErrors = 0;
|
|
2615
|
-
let paused = false;
|
|
2616
|
-
const MAX_ATTEMPTS = 40; // 40 × 3s = 2 minutes
|
|
2617
|
-
const MAX_CONSECUTIVE_ERRORS = 3;
|
|
2618
|
-
const POLL_INTERVAL = 3000;
|
|
2619
|
-
|
|
2620
|
-
const stopPolling = () => {
|
|
2621
|
-
if (statusPollIntervalRef.current) {
|
|
2622
|
-
clearTimeout(statusPollIntervalRef.current);
|
|
2623
|
-
statusPollIntervalRef.current = null;
|
|
2624
|
-
}
|
|
2625
|
-
if (popupCheckIntervalRef.current) {
|
|
2626
|
-
clearInterval(popupCheckIntervalRef.current);
|
|
2627
|
-
popupCheckIntervalRef.current = null;
|
|
2628
|
-
}
|
|
2629
|
-
verificationPopupRef.current?.close();
|
|
2630
|
-
verificationPopupRef.current = null;
|
|
2631
|
-
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
2632
|
-
pollingCleanupRef.current = null;
|
|
2633
|
-
setIsPollingStatus(false);
|
|
2634
|
-
isPollingStatusRef.current = false;
|
|
2635
|
-
setIsInitiatingVerification(false);
|
|
2636
|
-
};
|
|
2637
|
-
|
|
2638
|
-
const setError = (message: string, errorCode: string | null, canRetry: boolean) => {
|
|
2639
|
-
stopPolling();
|
|
2640
|
-
setVerificationError(message);
|
|
2641
|
-
setVerificationErrorCode(errorCode);
|
|
2642
|
-
setCanRetryVerification(canRetry);
|
|
2643
|
-
};
|
|
2644
|
-
|
|
2645
|
-
const poll = async () => {
|
|
2646
|
-
if (paused) return;
|
|
2647
|
-
|
|
2648
|
-
attempts++;
|
|
2649
|
-
|
|
2650
|
-
if (attempts > MAX_ATTEMPTS) {
|
|
2651
|
-
setError(
|
|
2652
|
-
"Verification is taking longer than expected. Please try again.",
|
|
2653
|
-
"SESSION_EXPIRED",
|
|
2654
|
-
true
|
|
2655
|
-
);
|
|
2656
|
-
return;
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
try {
|
|
2660
|
-
const response = await verifyGuardianStatus({
|
|
2661
|
-
accessToken,
|
|
2662
|
-
baseUrl,
|
|
2663
|
-
sessionToken: token,
|
|
2664
|
-
});
|
|
2665
|
-
|
|
2666
|
-
// Reset consecutive errors on any successful API call
|
|
2667
|
-
consecutiveErrors = 0;
|
|
2668
|
-
|
|
2669
|
-
if (response.status === "verified") {
|
|
2670
|
-
stopPolling();
|
|
2671
|
-
const ref = response.verification_reference || response.guardian_details?.verification_reference;
|
|
2672
|
-
if (!ref) {
|
|
2673
|
-
setError(
|
|
2674
|
-
"Verification completed but no reference received. Please try again.",
|
|
2675
|
-
null,
|
|
2676
|
-
true
|
|
2677
|
-
);
|
|
2678
|
-
return;
|
|
2679
|
-
}
|
|
2680
|
-
setVerificationReference(ref);
|
|
2681
|
-
setIsVerificationComplete(true);
|
|
2682
|
-
clearConsentState();
|
|
2683
|
-
return;
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
if (response.status === "failed" || response.status === "expired") {
|
|
2687
|
-
setError(
|
|
2688
|
-
getVerificationErrorMessage(response.error_code, response.error),
|
|
2689
|
-
response.error_code,
|
|
2690
|
-
response.can_retry
|
|
2691
|
-
);
|
|
2692
|
-
return;
|
|
2693
|
-
}
|
|
2694
|
-
|
|
2695
|
-
// pending / in_progress — poll again
|
|
2696
|
-
if (!paused) {
|
|
2697
|
-
statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
|
|
2698
|
-
}
|
|
2699
|
-
} catch (error) {
|
|
2700
|
-
const err = error as Error & { status?: number };
|
|
2701
|
-
|
|
2702
|
-
if (err.status === 404 || err.status === 410) {
|
|
2703
|
-
setError(
|
|
2704
|
-
"Verification session not found or expired. Please try again.",
|
|
2705
|
-
"SESSION_EXPIRED",
|
|
2706
|
-
true
|
|
2707
|
-
);
|
|
2708
|
-
return;
|
|
2709
|
-
}
|
|
2710
|
-
|
|
2711
|
-
// Transient error — keep polling, fail after consecutive threshold
|
|
2712
|
-
consecutiveErrors++;
|
|
2713
|
-
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
2714
|
-
setError(
|
|
2715
|
-
err.message || "Unable to check verification status. Please try again.",
|
|
2716
|
-
null,
|
|
2717
|
-
true
|
|
2718
|
-
);
|
|
2719
|
-
return;
|
|
2720
|
-
}
|
|
2721
|
-
|
|
2722
|
-
statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
|
|
2723
|
-
}
|
|
2724
|
-
};
|
|
2725
|
-
|
|
2726
|
-
// Pause when tab hidden, resume immediately when visible
|
|
2727
|
-
const onVisibilityChange = () => {
|
|
2728
|
-
if (document.hidden) {
|
|
2729
|
-
paused = true;
|
|
2730
|
-
if (statusPollIntervalRef.current) {
|
|
2731
|
-
clearTimeout(statusPollIntervalRef.current);
|
|
2732
|
-
statusPollIntervalRef.current = null;
|
|
2733
|
-
}
|
|
2734
|
-
} else {
|
|
2735
|
-
paused = false;
|
|
2736
|
-
poll();
|
|
2737
|
-
}
|
|
2738
|
-
};
|
|
2739
|
-
|
|
2740
|
-
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
2741
|
-
// Store cleanup so unmount/reset can remove the listener
|
|
2742
|
-
pollingCleanupRef.current = () => {
|
|
2743
|
-
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
2744
|
-
};
|
|
2745
|
-
|
|
2746
|
-
// First poll after 3s
|
|
2747
|
-
statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
|
|
2748
|
-
},
|
|
2749
|
-
[accessToken, sandboxParams, baseUrl, getVerificationErrorMessage]
|
|
2750
|
-
);
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
const handleBackToGuardianForm = useCallback((clearName = false) => {
|
|
2754
|
-
// Close any open popup
|
|
2755
|
-
verificationPopupRef.current?.close();
|
|
2756
|
-
verificationPopupRef.current = null;
|
|
2757
|
-
|
|
2758
|
-
// Clear all timers
|
|
2759
|
-
if (popupCheckIntervalRef.current) {
|
|
2760
|
-
clearInterval(popupCheckIntervalRef.current);
|
|
2761
|
-
popupCheckIntervalRef.current = null;
|
|
2762
|
-
}
|
|
2763
|
-
if (statusPollIntervalRef.current) {
|
|
2764
|
-
clearTimeout(statusPollIntervalRef.current);
|
|
2765
|
-
statusPollIntervalRef.current = null;
|
|
2766
|
-
}
|
|
2767
|
-
if (autoTransitionTimerRef.current) {
|
|
2768
|
-
clearTimeout(autoTransitionTimerRef.current);
|
|
2769
|
-
autoTransitionTimerRef.current = null;
|
|
2770
|
-
}
|
|
2771
|
-
if (digilockerRedirectTimeoutRef.current) {
|
|
2772
|
-
clearTimeout(digilockerRedirectTimeoutRef.current);
|
|
2773
|
-
digilockerRedirectTimeoutRef.current = null;
|
|
2774
|
-
}
|
|
2775
|
-
// Clean up visibilitychange listener
|
|
2776
|
-
pollingCleanupRef.current?.();
|
|
2777
|
-
pollingCleanupRef.current = null;
|
|
2778
|
-
|
|
2779
|
-
// Reset verification state
|
|
2780
|
-
setShowVerificationScreen(false);
|
|
2781
|
-
setShowGuardianForm(true);
|
|
2782
|
-
setVerificationError(null);
|
|
2783
|
-
setVerificationErrorCode(null);
|
|
2784
|
-
setCanRetryVerification(false);
|
|
2785
|
-
setIsInitiatingVerification(false);
|
|
2786
|
-
setIsPollingStatus(false);
|
|
2787
|
-
isPollingStatusRef.current = false;
|
|
2788
|
-
setIsVerificationComplete(false);
|
|
2789
|
-
|
|
2790
|
-
// For GUARDIAN_UNDER_18: clear name so user enters a different guardian
|
|
2791
|
-
if (clearName) {
|
|
2792
|
-
setGuardianFormData((prev) => ({ ...prev, guardianName: "" }));
|
|
2793
|
-
}
|
|
2794
|
-
|
|
2795
|
-
clearConsentState();
|
|
2796
|
-
}, []);
|
|
2797
|
-
|
|
2798
|
-
const availableLanguages = useMemo(() => {
|
|
2799
|
-
if (!content) return [language];
|
|
2800
|
-
|
|
2801
|
-
// English is always pinned first since it's in active_config.
|
|
2802
|
-
// Non-English default_language is pinned second so users can find it quickly.
|
|
2803
|
-
// Remaining languages are sorted A→Z by English name.
|
|
2804
|
-
const pinned = ["English"];
|
|
2805
|
-
if (
|
|
2806
|
-
content.default_language &&
|
|
2807
|
-
content.default_language !== "English" &&
|
|
2808
|
-
content.default_language !== "en"
|
|
2809
|
-
) {
|
|
2810
|
-
pinned.push(content.default_language);
|
|
2811
|
-
}
|
|
2812
|
-
|
|
2813
|
-
const rest = content.supported_languages_and_translations
|
|
2814
|
-
? Object.keys(content.supported_languages_and_translations)
|
|
2815
|
-
.filter((lang) => !pinned.includes(lang))
|
|
2816
|
-
.sort((a, b) => a.localeCompare(b, "en"))
|
|
2817
|
-
: [];
|
|
2818
|
-
|
|
2819
|
-
return [...pinned, ...rest];
|
|
2820
|
-
}, [content, language]);
|
|
2821
|
-
|
|
2822
|
-
/**
|
|
2823
|
-
* Maps display language name to language code for API calls
|
|
2824
|
-
* "English" -> "English", other languages use their key from supported_languages_and_translations
|
|
2825
|
-
*/
|
|
2826
|
-
const getLanguageCode = useCallback(
|
|
2827
|
-
(displayLanguage: string): string => {
|
|
2828
|
-
if (!content) return "English";
|
|
2829
|
-
|
|
2830
|
-
// English maps to "English" for audio API
|
|
2831
|
-
if (displayLanguage === "English" || displayLanguage === "en") {
|
|
2832
|
-
return "English";
|
|
2833
|
-
}
|
|
2834
|
-
|
|
2835
|
-
// Check if the display language is a key in supported_languages_and_translations
|
|
2836
|
-
// If so, use it directly (it's already a language code)
|
|
2837
|
-
if (
|
|
2838
|
-
content.supported_languages_and_translations &&
|
|
2839
|
-
content.supported_languages_and_translations[displayLanguage]
|
|
2840
|
-
) {
|
|
2841
|
-
return displayLanguage;
|
|
2842
|
-
}
|
|
2843
|
-
|
|
2844
|
-
// Fallback to default language or "English"
|
|
2845
|
-
return content.default_language === "en" ||
|
|
2846
|
-
content.default_language === "EN"
|
|
2847
|
-
? "English"
|
|
2848
|
-
: content.default_language || "English";
|
|
2849
|
-
},
|
|
2850
|
-
[content]
|
|
2851
|
-
);
|
|
2852
|
-
|
|
2853
|
-
// Build audio sequence array matching UI text order
|
|
2854
|
-
const audioSequence = useMemo(() => {
|
|
2855
|
-
if (!ttsAudioUrls || !content) return [];
|
|
2856
|
-
|
|
2857
|
-
const sequence: string[] = [];
|
|
2858
|
-
|
|
2859
|
-
// 1. Notice banner heading
|
|
2860
|
-
if (ttsAudioUrls.notice_banner_heading_audio_url) {
|
|
2861
|
-
sequence.push(ttsAudioUrls.notice_banner_heading_audio_url);
|
|
2862
|
-
}
|
|
2863
|
-
|
|
2864
|
-
// 2. Notice text
|
|
2865
|
-
if (ttsAudioUrls.notice_text_audio_url) {
|
|
2866
|
-
sequence.push(ttsAudioUrls.notice_text_audio_url);
|
|
2867
|
-
}
|
|
2868
|
-
|
|
2869
|
-
// 3. Privacy policy prefix text
|
|
2870
|
-
if (ttsAudioUrls.privacy_policy_prefix_text_audio_url) {
|
|
2871
|
-
sequence.push(ttsAudioUrls.privacy_policy_prefix_text_audio_url);
|
|
2872
|
-
}
|
|
2873
|
-
|
|
2874
|
-
// 4. Privacy policy anchor text
|
|
2875
|
-
if (ttsAudioUrls.privacy_policy_anchor_text_audio_url) {
|
|
2876
|
-
sequence.push(ttsAudioUrls.privacy_policy_anchor_text_audio_url);
|
|
2877
|
-
}
|
|
2878
|
-
|
|
2879
|
-
// 5. Purpose section heading
|
|
2880
|
-
if (ttsAudioUrls.purpose_section_heading_audio_url) {
|
|
2881
|
-
sequence.push(ttsAudioUrls.purpose_section_heading_audio_url);
|
|
2882
|
-
}
|
|
2883
|
-
|
|
2884
|
-
// 6. Purposes and data elements
|
|
2885
|
-
// Determine which purposes to include based on UI mode
|
|
2886
|
-
const purposesToProcess = categorizedPurposes
|
|
2887
|
-
? [
|
|
2888
|
-
...categorizedPurposes.alreadyConsented,
|
|
2889
|
-
...categorizedPurposes.needsConsent,
|
|
2890
|
-
]
|
|
2891
|
-
: content.purposes || [];
|
|
2892
|
-
|
|
2893
|
-
purposesToProcess.forEach((purpose) => {
|
|
2894
|
-
// Purpose name audio
|
|
2895
|
-
if (ttsAudioUrls.purposes_audio[purpose.uuid]?.name_audio_url) {
|
|
2896
|
-
sequence.push(ttsAudioUrls.purposes_audio[purpose.uuid].name_audio_url);
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
// Purpose description audio
|
|
2900
|
-
if (ttsAudioUrls.purposes_audio[purpose.uuid]?.description_audio_url) {
|
|
2901
|
-
sequence.push(
|
|
2902
|
-
ttsAudioUrls.purposes_audio[purpose.uuid].description_audio_url
|
|
2903
|
-
);
|
|
2904
|
-
}
|
|
2905
|
-
|
|
2906
|
-
// Data elements audio
|
|
2907
|
-
if (purpose.data_elements && purpose.data_elements.length > 0) {
|
|
2908
|
-
purpose.data_elements.forEach((dataElement) => {
|
|
2909
|
-
if (
|
|
2910
|
-
ttsAudioUrls.data_elements_audio[dataElement.uuid]?.name_audio_url
|
|
2911
|
-
) {
|
|
2912
|
-
sequence.push(
|
|
2913
|
-
ttsAudioUrls.data_elements_audio[dataElement.uuid].name_audio_url
|
|
2914
|
-
);
|
|
2915
|
-
}
|
|
2916
|
-
});
|
|
2917
|
-
}
|
|
2918
|
-
});
|
|
2919
|
-
|
|
2920
|
-
// 7. Additional text audio
|
|
2921
|
-
if (content.additional_text && ttsAudioUrls.additional_text_audio_url) {
|
|
2922
|
-
sequence.push(ttsAudioUrls.additional_text_audio_url);
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
// 8. Privacy center anchor text audio
|
|
2926
|
-
if (content.privacy_center_url && ttsAudioUrls.privacy_center_anchor_text_audio_url) {
|
|
2927
|
-
sequence.push(ttsAudioUrls.privacy_center_anchor_text_audio_url);
|
|
2928
|
-
}
|
|
2929
|
-
|
|
2930
|
-
// 9. DPO info audio
|
|
2931
|
-
if (content.dpo_info && ttsAudioUrls.dpo_info_audio) {
|
|
2932
|
-
const dpoAudio = ttsAudioUrls.dpo_info_audio;
|
|
2933
|
-
DPO_AUDIO_ELEMENT_MAP.forEach(({ audioKey }) => {
|
|
2934
|
-
if (dpoAudio[audioKey]) {
|
|
2935
|
-
sequence.push(dpoAudio[audioKey]);
|
|
2936
|
-
}
|
|
2937
|
-
});
|
|
2938
|
-
}
|
|
2939
|
-
|
|
2940
|
-
// 10. Confirm button text audio
|
|
2941
|
-
if (ttsAudioUrls.confirm_button_text_audio_url) {
|
|
2942
|
-
sequence.push(ttsAudioUrls.confirm_button_text_audio_url);
|
|
2943
|
-
}
|
|
2944
|
-
|
|
2945
|
-
// 11. Decline button text audio
|
|
2946
|
-
if (ttsAudioUrls.decline_button_text_audio_url) {
|
|
2947
|
-
sequence.push(ttsAudioUrls.decline_button_text_audio_url);
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
return sequence;
|
|
2951
|
-
}, [ttsAudioUrls, content, categorizedPurposes]);
|
|
2952
|
-
|
|
2953
|
-
// Build element ID sequence array matching audio sequence order
|
|
2954
|
-
const elementIdSequence = useMemo(() => {
|
|
2955
|
-
if (!ttsAudioUrls || !content) return [];
|
|
2956
|
-
|
|
2957
|
-
const elementIds: (string | null)[] = [];
|
|
2958
|
-
|
|
2959
|
-
// 1. Notice banner heading
|
|
2960
|
-
if (ttsAudioUrls.notice_banner_heading_audio_url) {
|
|
2961
|
-
elementIds.push("privacy-notice-title");
|
|
2962
|
-
}
|
|
2963
|
-
|
|
2964
|
-
// 2. Notice text
|
|
2965
|
-
if (ttsAudioUrls.notice_text_audio_url) {
|
|
2966
|
-
elementIds.push("privacy-notice-text");
|
|
2967
|
-
}
|
|
2968
|
-
|
|
2969
|
-
// 3. Privacy policy prefix text
|
|
2970
|
-
if (ttsAudioUrls.privacy_policy_prefix_text_audio_url) {
|
|
2971
|
-
elementIds.push("privacy-policy-text");
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
// 4. Privacy policy anchor text
|
|
2975
|
-
if (ttsAudioUrls.privacy_policy_anchor_text_audio_url) {
|
|
2976
|
-
elementIds.push("privacy-policy-link");
|
|
2977
|
-
}
|
|
2978
|
-
|
|
2979
|
-
// 5. Purpose section heading
|
|
2980
|
-
if (ttsAudioUrls.purpose_section_heading_audio_url) {
|
|
2981
|
-
elementIds.push("purpose-section-heading");
|
|
2982
|
-
}
|
|
2983
|
-
|
|
2984
|
-
// 6. Purposes and data elements
|
|
2985
|
-
const purposesToProcess = categorizedPurposes
|
|
2986
|
-
? [
|
|
2987
|
-
...categorizedPurposes.alreadyConsented,
|
|
2988
|
-
...categorizedPurposes.needsConsent,
|
|
2989
|
-
]
|
|
2990
|
-
: content.purposes || [];
|
|
2991
|
-
|
|
2992
|
-
purposesToProcess.forEach((purpose) => {
|
|
2993
|
-
// Purpose name
|
|
2994
|
-
if (ttsAudioUrls.purposes_audio[purpose.uuid]?.name_audio_url) {
|
|
2995
|
-
elementIds.push(`purpose-name-${purpose.uuid}`);
|
|
2996
|
-
}
|
|
2997
|
-
|
|
2998
|
-
// Purpose description
|
|
2999
|
-
if (ttsAudioUrls.purposes_audio[purpose.uuid]?.description_audio_url) {
|
|
3000
|
-
elementIds.push(`purpose-description-${purpose.uuid}`);
|
|
3001
|
-
}
|
|
3002
|
-
|
|
3003
|
-
// Data elements
|
|
3004
|
-
if (purpose.data_elements && purpose.data_elements.length > 0) {
|
|
3005
|
-
purpose.data_elements.forEach((dataElement) => {
|
|
3006
|
-
if (
|
|
3007
|
-
ttsAudioUrls.data_elements_audio[dataElement.uuid]?.name_audio_url
|
|
3008
|
-
) {
|
|
3009
|
-
elementIds.push(`data-element-${purpose.uuid}-${dataElement.uuid}`);
|
|
3010
|
-
}
|
|
3011
|
-
});
|
|
3012
|
-
}
|
|
3013
|
-
});
|
|
3014
|
-
|
|
3015
|
-
// 7. Additional text audio
|
|
3016
|
-
if (content.additional_text && ttsAudioUrls.additional_text_audio_url) {
|
|
3017
|
-
elementIds.push("additional-text");
|
|
3018
|
-
}
|
|
3019
|
-
|
|
3020
|
-
// 8. Privacy center anchor text audio
|
|
3021
|
-
if (content.privacy_center_url && ttsAudioUrls.privacy_center_anchor_text_audio_url) {
|
|
3022
|
-
elementIds.push("privacy-center-link");
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
// 9. DPO info audio
|
|
3026
|
-
if (content.dpo_info && ttsAudioUrls.dpo_info_audio) {
|
|
3027
|
-
const dpoAudio = ttsAudioUrls.dpo_info_audio;
|
|
3028
|
-
DPO_AUDIO_ELEMENT_MAP.forEach(({ audioKey, elementId }) => {
|
|
3029
|
-
if (dpoAudio[audioKey]) {
|
|
3030
|
-
elementIds.push(elementId);
|
|
3031
|
-
}
|
|
3032
|
-
});
|
|
3033
|
-
}
|
|
3034
|
-
|
|
3035
|
-
// 10. Confirm button text audio
|
|
3036
|
-
if (ttsAudioUrls.confirm_button_text_audio_url) {
|
|
3037
|
-
elementIds.push("confirm-button-text");
|
|
3038
|
-
}
|
|
3039
|
-
|
|
3040
|
-
// 11. Decline button text audio
|
|
3041
|
-
if (ttsAudioUrls.decline_button_text_audio_url) {
|
|
3042
|
-
elementIds.push("decline-button-text");
|
|
3043
|
-
}
|
|
3044
|
-
|
|
3045
|
-
return elementIds;
|
|
3046
|
-
}, [ttsAudioUrls, content, categorizedPurposes]);
|
|
3047
|
-
|
|
3048
|
-
// Audio playback functions
|
|
3049
|
-
const stopAudio = useCallback(() => {
|
|
3050
|
-
if (currentAudioRef.current) {
|
|
3051
|
-
currentAudioRef.current.pause();
|
|
3052
|
-
currentAudioRef.current.currentTime = 0;
|
|
3053
|
-
currentAudioRef.current = null;
|
|
3054
|
-
}
|
|
3055
|
-
setIsPlaying(false);
|
|
3056
|
-
setIsPaused(false);
|
|
3057
|
-
setCurrentAudioIndex(0);
|
|
3058
|
-
setHighlightedElementId(null);
|
|
3059
|
-
}, []);
|
|
3060
|
-
|
|
3061
|
-
const playNextAudio = useCallback(
|
|
3062
|
-
(index: number) => {
|
|
3063
|
-
if (index >= audioSequence.length) {
|
|
3064
|
-
// Finished all audio
|
|
3065
|
-
stopAudio();
|
|
3066
|
-
return;
|
|
3067
|
-
}
|
|
3068
|
-
|
|
3069
|
-
// Stop current audio if playing
|
|
3070
|
-
if (currentAudioRef.current) {
|
|
3071
|
-
currentAudioRef.current.pause();
|
|
3072
|
-
currentAudioRef.current = null;
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
// Create new audio element
|
|
3076
|
-
const audio = new Audio(audioSequence[index]);
|
|
3077
|
-
currentAudioRef.current = audio;
|
|
3078
|
-
setCurrentAudioIndex(index);
|
|
3079
|
-
setIsPlaying(true);
|
|
3080
|
-
setIsPaused(false);
|
|
3081
|
-
|
|
3082
|
-
// Highlight corresponding element
|
|
3083
|
-
const elementId = elementIdSequence[index];
|
|
3084
|
-
if (elementId) {
|
|
3085
|
-
setHighlightedElementId(elementId);
|
|
3086
|
-
}
|
|
3087
|
-
|
|
3088
|
-
// Handle audio end - play next in sequence
|
|
3089
|
-
audio.onended = () => {
|
|
3090
|
-
playNextAudio(index + 1);
|
|
3091
|
-
};
|
|
3092
|
-
|
|
3093
|
-
// Handle errors
|
|
3094
|
-
audio.onerror = () => {
|
|
3095
|
-
console.warn(`Failed to load audio at index ${index}`);
|
|
3096
|
-
// Skip to next audio on error
|
|
3097
|
-
playNextAudio(index + 1);
|
|
3098
|
-
};
|
|
3099
|
-
|
|
3100
|
-
// Play the audio
|
|
3101
|
-
audio.play().catch((error) => {
|
|
3102
|
-
console.warn(`Failed to play audio at index ${index}:`, error);
|
|
3103
|
-
setIsPlaying(false);
|
|
3104
|
-
});
|
|
3105
|
-
},
|
|
3106
|
-
[audioSequence, elementIdSequence, stopAudio]
|
|
3107
|
-
);
|
|
3108
|
-
|
|
3109
|
-
const playAudio = useCallback(() => {
|
|
3110
|
-
if (audioSequence.length === 0) return;
|
|
3111
|
-
|
|
3112
|
-
if (isPaused && currentAudioRef.current) {
|
|
3113
|
-
// Resume paused audio
|
|
3114
|
-
currentAudioRef.current
|
|
3115
|
-
.play()
|
|
3116
|
-
.then(() => {
|
|
3117
|
-
setIsPlaying(true);
|
|
3118
|
-
setIsPaused(false);
|
|
3119
|
-
})
|
|
3120
|
-
.catch((error) => {
|
|
3121
|
-
console.warn("Failed to resume audio:", error);
|
|
3122
|
-
});
|
|
3123
|
-
} else {
|
|
3124
|
-
// Start from beginning or current index
|
|
3125
|
-
const startIndex = isPlaying ? currentAudioIndex : 0;
|
|
3126
|
-
playNextAudio(startIndex);
|
|
3127
|
-
}
|
|
3128
|
-
}, [audioSequence, isPaused, isPlaying, currentAudioIndex, playNextAudio]);
|
|
3129
|
-
|
|
3130
|
-
const pauseAudio = useCallback(() => {
|
|
3131
|
-
if (currentAudioRef.current && isPlaying) {
|
|
3132
|
-
currentAudioRef.current.pause();
|
|
3133
|
-
setIsPlaying(false);
|
|
3134
|
-
setIsPaused(true);
|
|
3135
|
-
}
|
|
3136
|
-
}, [isPlaying]);
|
|
3137
|
-
|
|
3138
|
-
const toggleAudio = useCallback(() => {
|
|
3139
|
-
if (isPlaying) {
|
|
3140
|
-
pauseAudio();
|
|
3141
|
-
} else {
|
|
3142
|
-
playAudio();
|
|
3143
|
-
}
|
|
3144
|
-
}, [isPlaying, playAudio, pauseAudio]);
|
|
3145
|
-
|
|
3146
|
-
// Auto-expand collapsed purposes when their audio is playing
|
|
3147
|
-
useEffect(() => {
|
|
3148
|
-
if (!isPlaying || !content || audioSequence.length === 0) return;
|
|
3149
|
-
|
|
3150
|
-
// Determine which purpose corresponds to current audio index
|
|
3151
|
-
// This is approximate - we'll expand purposes as we progress through the sequence
|
|
3152
|
-
const currentIndex = currentAudioIndex;
|
|
3153
|
-
|
|
3154
|
-
// Calculate approximate purpose index based on audio sequence position
|
|
3155
|
-
// Skip header audio (notice_banner, notice_text, privacy_policy, purpose_section = ~4-5 items)
|
|
3156
|
-
const headerAudioCount = 5;
|
|
3157
|
-
if (currentIndex < headerAudioCount) return;
|
|
3158
|
-
|
|
3159
|
-
// Find which purpose we're on (rough estimate)
|
|
3160
|
-
const purposesToProcess = categorizedPurposes
|
|
3161
|
-
? [
|
|
3162
|
-
...categorizedPurposes.alreadyConsented,
|
|
3163
|
-
...categorizedPurposes.needsConsent,
|
|
3164
|
-
]
|
|
3165
|
-
: content.purposes || [];
|
|
3166
|
-
|
|
3167
|
-
let audioOffset = headerAudioCount;
|
|
3168
|
-
for (const purpose of purposesToProcess) {
|
|
3169
|
-
// Purpose name + description = 2 audio items
|
|
3170
|
-
const purposeAudioCount = 2;
|
|
3171
|
-
const dataElementsAudioCount = purpose.data_elements?.length || 0;
|
|
3172
|
-
const totalPurposeAudioCount = purposeAudioCount + dataElementsAudioCount;
|
|
3173
|
-
|
|
3174
|
-
if (
|
|
3175
|
-
currentIndex >= audioOffset &&
|
|
3176
|
-
currentIndex < audioOffset + totalPurposeAudioCount
|
|
3177
|
-
) {
|
|
3178
|
-
// Expand this purpose if collapsed
|
|
3179
|
-
if (collapsedPurposes[purpose.uuid]) {
|
|
3180
|
-
setCollapsedPurposes((prev) => ({
|
|
3181
|
-
...prev,
|
|
3182
|
-
[purpose.uuid]: false,
|
|
3183
|
-
}));
|
|
3184
|
-
}
|
|
3185
|
-
break;
|
|
3186
|
-
}
|
|
3187
|
-
|
|
3188
|
-
audioOffset += totalPurposeAudioCount;
|
|
3189
|
-
}
|
|
3190
|
-
}, [
|
|
3191
|
-
isPlaying,
|
|
3192
|
-
currentAudioIndex,
|
|
3193
|
-
audioSequence,
|
|
3194
|
-
content,
|
|
3195
|
-
categorizedPurposes,
|
|
3196
|
-
collapsedPurposes,
|
|
3197
|
-
]);
|
|
3198
|
-
|
|
3199
|
-
// Highlight and scroll to element when audio is playing
|
|
3200
|
-
useEffect(() => {
|
|
3201
|
-
// Remove highlight from all elements
|
|
3202
|
-
const removeAllHighlights = () => {
|
|
3203
|
-
document.querySelectorAll(".redacto-tts-highlight").forEach((el) => {
|
|
3204
|
-
el.classList.remove("redacto-tts-highlight");
|
|
3205
|
-
});
|
|
3206
|
-
};
|
|
3207
|
-
|
|
3208
|
-
if (!highlightedElementId || !isPlaying) {
|
|
3209
|
-
removeAllHighlights();
|
|
3210
|
-
return;
|
|
3211
|
-
}
|
|
3212
|
-
|
|
3213
|
-
// Remove previous highlights
|
|
3214
|
-
removeAllHighlights();
|
|
3215
|
-
|
|
3216
|
-
// Use setTimeout to ensure DOM is ready and styles are applied
|
|
3217
|
-
const highlightTimeout = setTimeout(() => {
|
|
3218
|
-
// Find and highlight the current element
|
|
3219
|
-
const element = document.getElementById(highlightedElementId);
|
|
3220
|
-
if (element) {
|
|
3221
|
-
// Add highlight class
|
|
3222
|
-
element.classList.add("redacto-tts-highlight");
|
|
3223
|
-
|
|
3224
|
-
// Force a repaint to ensure styles are applied
|
|
3225
|
-
void element.offsetHeight;
|
|
3226
|
-
|
|
3227
|
-
// Scroll element into view (only if it's not already visible)
|
|
3228
|
-
const rect = element.getBoundingClientRect();
|
|
3229
|
-
const isVisible =
|
|
3230
|
-
rect.top >= 0 &&
|
|
3231
|
-
rect.left >= 0 &&
|
|
3232
|
-
rect.bottom <=
|
|
3233
|
-
(window.innerHeight || document.documentElement.clientHeight) &&
|
|
3234
|
-
rect.right <=
|
|
3235
|
-
(window.innerWidth || document.documentElement.clientWidth);
|
|
3236
|
-
|
|
3237
|
-
if (!isVisible) {
|
|
3238
|
-
element.scrollIntoView({
|
|
3239
|
-
behavior: "smooth",
|
|
3240
|
-
block: "center",
|
|
3241
|
-
inline: "nearest",
|
|
3242
|
-
});
|
|
3243
|
-
}
|
|
3244
|
-
} else {
|
|
3245
|
-
// Debug: log if element not found
|
|
3246
|
-
console.warn(
|
|
3247
|
-
`TTS Highlight: Element with ID "${highlightedElementId}" not found`
|
|
3248
|
-
);
|
|
3249
|
-
}
|
|
3250
|
-
}, 50); // Small delay to ensure DOM is ready
|
|
3251
|
-
|
|
3252
|
-
// Cleanup function
|
|
3253
|
-
return () => {
|
|
3254
|
-
clearTimeout(highlightTimeout);
|
|
3255
|
-
removeAllHighlights();
|
|
3256
|
-
};
|
|
3257
|
-
}, [highlightedElementId, isPlaying]);
|
|
3258
|
-
|
|
3259
|
-
// Fetch TTS audio URLs when content loads and language changes
|
|
3260
|
-
useEffect(() => {
|
|
3261
|
-
// Reset TTS availability when content or language changes
|
|
3262
|
-
setIsTTSAvailable(false);
|
|
3263
|
-
setTtsAudioUrls(null);
|
|
3264
|
-
stopAudio();
|
|
3265
|
-
|
|
3266
|
-
// Only fetch if content is loaded and has notice_uuid
|
|
3267
|
-
if (!content || !content.notice_uuid) {
|
|
3268
|
-
return;
|
|
3269
|
-
}
|
|
3270
|
-
|
|
3271
|
-
// Get language code from display language name
|
|
3272
|
-
const languageCode = getLanguageCode(selectedLanguage);
|
|
3273
|
-
|
|
3274
|
-
// Create abort controller for this request
|
|
3275
|
-
const ttsAbortController = new AbortController();
|
|
3276
|
-
|
|
3277
|
-
// Fetch TTS audio URLs
|
|
3278
|
-
fetchTTSAudioUrls({
|
|
3279
|
-
accessToken,
|
|
3280
|
-
baseUrl,
|
|
3281
|
-
noticeUuid: content.notice_uuid,
|
|
3282
|
-
language: languageCode,
|
|
3283
|
-
signal: ttsAbortController.signal,
|
|
3284
|
-
...sandboxParams,
|
|
3285
|
-
})
|
|
3286
|
-
.then((response) => {
|
|
3287
|
-
// Store the response and mark TTS as available
|
|
3288
|
-
setTtsAudioUrls(response.detail);
|
|
3289
|
-
setIsTTSAvailable(true);
|
|
3290
|
-
})
|
|
3291
|
-
.catch((error) => {
|
|
3292
|
-
// Handle errors gracefully - don't break the UI if TTS API fails
|
|
3293
|
-
if (error instanceof Error && error.name !== "AbortError") {
|
|
3294
|
-
setIsTTSAvailable(false);
|
|
3295
|
-
setTtsAudioUrls(null);
|
|
3296
|
-
}
|
|
3297
|
-
});
|
|
3298
|
-
|
|
3299
|
-
// Cleanup: abort request if component unmounts or dependencies change
|
|
3300
|
-
return () => {
|
|
3301
|
-
ttsAbortController.abort();
|
|
3302
|
-
};
|
|
3303
|
-
}, [content, selectedLanguage, accessToken, sandboxParams, baseUrl, getLanguageCode]);
|
|
3304
|
-
|
|
3305
|
-
const modalStyle = {
|
|
3306
|
-
borderRadius: settings?.borderRadius || "8px",
|
|
3307
|
-
backgroundColor: settings?.backgroundColor || "#ffffff",
|
|
3308
|
-
};
|
|
3309
|
-
|
|
3310
|
-
const buttonStyle = {
|
|
3311
|
-
borderRadius: settings?.borderRadius || "8px",
|
|
3312
|
-
};
|
|
3313
|
-
|
|
3314
|
-
const acceptButtonStyle = {
|
|
3315
|
-
...buttonStyle,
|
|
3316
|
-
backgroundColor:
|
|
3317
|
-
settings?.button?.accept?.backgroundColor ||
|
|
3318
|
-
content?.primary_color ||
|
|
3319
|
-
"#4f87ff",
|
|
3320
|
-
color: settings?.button?.accept?.textColor || "#ffffff",
|
|
3321
|
-
};
|
|
3322
|
-
|
|
3323
|
-
const declineButtonStyle = {
|
|
3324
|
-
...buttonStyle,
|
|
3325
|
-
backgroundColor: settings?.button?.decline?.backgroundColor || "#ffffff",
|
|
3326
|
-
color: settings?.button?.decline?.textColor || "#000000",
|
|
3327
|
-
borderColor: settings?.borderColor || "#d0d5dd",
|
|
3328
|
-
};
|
|
3329
|
-
|
|
3330
|
-
const linkStyle = {
|
|
3331
|
-
color: settings?.link || content?.secondary_color || "#4f87ff",
|
|
3332
|
-
};
|
|
3333
|
-
|
|
3334
|
-
const languageSelectorStyle = {
|
|
3335
|
-
borderRadius: settings?.borderRadius || "8px",
|
|
3336
|
-
borderColor: settings?.borderColor || "#d0d5dd",
|
|
3337
|
-
backgroundColor: settings?.button?.language?.backgroundColor || "#ffffff",
|
|
3338
|
-
color: settings?.button?.language?.textColor || "#344054",
|
|
3339
|
-
};
|
|
3340
|
-
|
|
3341
|
-
const selectedLanguageItemStyle = {
|
|
3342
|
-
...languageSelectorStyle,
|
|
3343
|
-
backgroundColor:
|
|
3344
|
-
settings?.button?.language?.selectedBackgroundColor || "#f3f4f6",
|
|
3345
|
-
color: settings?.button?.language?.selectedTextColor || "#344054",
|
|
3346
|
-
fontWeight: 600,
|
|
3347
|
-
};
|
|
3348
|
-
|
|
3349
|
-
const handleCloseError = () => {
|
|
3350
|
-
setErrorMessage(null);
|
|
3351
|
-
};
|
|
3352
|
-
|
|
3353
|
-
// Add keyboard event handler for the modal
|
|
3354
|
-
useEffect(() => {
|
|
3355
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
3356
|
-
if (!modalRef.current) return;
|
|
3357
|
-
|
|
3358
|
-
const focusableElements = modalRef.current.querySelectorAll(
|
|
3359
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
3360
|
-
);
|
|
3361
|
-
if (!focusableElements.length) return;
|
|
3362
|
-
|
|
3363
|
-
if (event.key === "Escape") {
|
|
3364
|
-
handleDecline();
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
if (event.key === "Tab") {
|
|
3368
|
-
const firstElement = focusableElements[0] as HTMLElement;
|
|
3369
|
-
const lastElement = focusableElements[
|
|
3370
|
-
focusableElements.length - 1
|
|
3371
|
-
] as HTMLElement;
|
|
3372
|
-
|
|
3373
|
-
if (event.shiftKey) {
|
|
3374
|
-
if (document.activeElement === firstElement) {
|
|
3375
|
-
event.preventDefault();
|
|
3376
|
-
lastElement.focus();
|
|
3377
|
-
}
|
|
3378
|
-
} else {
|
|
3379
|
-
if (document.activeElement === lastElement) {
|
|
3380
|
-
event.preventDefault();
|
|
3381
|
-
firstElement.focus();
|
|
3382
|
-
}
|
|
3383
|
-
}
|
|
3384
|
-
}
|
|
3385
|
-
};
|
|
3386
|
-
|
|
3387
|
-
const modal = modalRef.current;
|
|
3388
|
-
modal?.addEventListener("keydown", handleKeyDown);
|
|
3389
|
-
|
|
3390
|
-
// Focus the first focusable element when modal opens
|
|
3391
|
-
if (modal && !isLoading) {
|
|
3392
|
-
const focusableElements = modal.querySelectorAll(
|
|
3393
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
3394
|
-
);
|
|
3395
|
-
const firstElement = focusableElements[0] as HTMLElement;
|
|
3396
|
-
firstElement?.focus();
|
|
3397
|
-
}
|
|
3398
|
-
|
|
3399
|
-
return () => {
|
|
3400
|
-
modal?.removeEventListener("keydown", handleKeyDown);
|
|
3401
|
-
};
|
|
3402
|
-
}, [isLoading, handleDecline]);
|
|
3403
|
-
|
|
3404
|
-
// Stop audio when language changes
|
|
3405
|
-
useEffect(() => {
|
|
3406
|
-
stopAudio();
|
|
3407
|
-
}, [selectedLanguage, stopAudio]);
|
|
3408
|
-
|
|
3409
|
-
// Stop audio when content changes
|
|
3410
|
-
useEffect(() => {
|
|
3411
|
-
stopAudio();
|
|
3412
|
-
}, [content, stopAudio]);
|
|
3413
|
-
|
|
3414
|
-
// Cleanup effect - cancel any pending requests and stop audio when component unmounts
|
|
3415
|
-
useEffect(() => {
|
|
3416
|
-
return () => {
|
|
3417
|
-
// Cancel any pending fetch request
|
|
3418
|
-
if (abortControllerRef.current) {
|
|
3419
|
-
abortControllerRef.current.abort();
|
|
3420
|
-
}
|
|
3421
|
-
// Stop and cleanup audio
|
|
3422
|
-
stopAudio();
|
|
3423
|
-
// Cleanup verification intervals, popup, and visibilitychange listener
|
|
3424
|
-
if (popupCheckIntervalRef.current) {
|
|
3425
|
-
clearInterval(popupCheckIntervalRef.current);
|
|
3426
|
-
}
|
|
3427
|
-
if (statusPollIntervalRef.current) {
|
|
3428
|
-
clearTimeout(statusPollIntervalRef.current);
|
|
3429
|
-
}
|
|
3430
|
-
pollingCleanupRef.current?.();
|
|
3431
|
-
pollingCleanupRef.current = null;
|
|
3432
|
-
verificationPopupRef.current?.close();
|
|
3433
|
-
if (autoTransitionTimerRef.current) {
|
|
3434
|
-
clearTimeout(autoTransitionTimerRef.current);
|
|
3435
|
-
autoTransitionTimerRef.current = null;
|
|
3436
|
-
}
|
|
3437
|
-
if (digilockerRedirectTimeoutRef.current) {
|
|
3438
|
-
clearTimeout(digilockerRedirectTimeoutRef.current);
|
|
3439
|
-
digilockerRedirectTimeoutRef.current = null;
|
|
3440
|
-
}
|
|
3441
|
-
};
|
|
3442
|
-
}, [stopAudio]);
|
|
3443
|
-
|
|
3444
|
-
// Restore state from redirect flow (when user returns from DigiLocker)
|
|
3445
|
-
useEffect(() => {
|
|
3446
|
-
const persistedState = getConsentState();
|
|
3447
|
-
if (!persistedState) return;
|
|
3448
|
-
|
|
3449
|
-
// Validate persisted state belongs to this notice and hasn't expired
|
|
3450
|
-
if (persistedState.noticeId && persistedState.noticeId !== noticeId) {
|
|
3451
|
-
clearConsentState();
|
|
3452
|
-
return;
|
|
3453
|
-
}
|
|
3454
|
-
const REDIRECT_STATE_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
|
|
3455
|
-
if (persistedState.timestamp) {
|
|
3456
|
-
const stateAge = Date.now() - new Date(persistedState.timestamp).getTime();
|
|
3457
|
-
if (stateAge > REDIRECT_STATE_MAX_AGE_MS) {
|
|
3458
|
-
clearConsentState();
|
|
3459
|
-
return;
|
|
3460
|
-
}
|
|
3461
|
-
}
|
|
3462
|
-
|
|
3463
|
-
// Check if this is a redirect return
|
|
3464
|
-
if (persistedState.flowState === "digilocker-pending" && persistedState.sessionToken) {
|
|
3465
|
-
// Resume verification status polling
|
|
3466
|
-
setShowVerificationScreen(true);
|
|
3467
|
-
setShowGuardianForm(false);
|
|
3468
|
-
setShowAgeVerification(false);
|
|
3469
|
-
|
|
3470
|
-
// Start polling for status
|
|
3471
|
-
startStatusPolling(persistedState.sessionToken);
|
|
3472
|
-
} else if (persistedState.verificationResult === "verified" && persistedState.verificationReference) {
|
|
3473
|
-
// Verification completed during redirect, show success screen with Continue button
|
|
3474
|
-
setVerificationReference(persistedState.verificationReference);
|
|
3475
|
-
setShowVerificationScreen(true);
|
|
3476
|
-
setIsVerificationComplete(true);
|
|
3477
|
-
setShowGuardianForm(false);
|
|
3478
|
-
setShowAgeVerification(false);
|
|
3479
|
-
clearConsentState();
|
|
3480
|
-
} else if (persistedState.verificationResult === "failed" || persistedState.verificationResult === "expired") {
|
|
3481
|
-
// Verification failed during redirect, show error
|
|
3482
|
-
setShowVerificationScreen(true);
|
|
3483
|
-
setShowGuardianForm(false);
|
|
3484
|
-
setShowAgeVerification(false);
|
|
3485
|
-
setVerificationError(
|
|
3486
|
-
persistedState.verificationResult === "expired"
|
|
3487
|
-
? "Verification session has expired. Please try again."
|
|
3488
|
-
: "Verification failed. Please try again."
|
|
3489
|
-
);
|
|
3490
|
-
clearConsentState();
|
|
3491
|
-
}
|
|
3492
|
-
}, [startStatusPolling, noticeId]);
|
|
3493
|
-
|
|
3494
|
-
// =============================================================================
|
|
3495
|
-
// RENDER
|
|
3496
|
-
// =============================================================================
|
|
3497
|
-
|
|
3498
|
-
return (
|
|
3499
|
-
<>
|
|
3500
|
-
{hasAlreadyConsented ? null : (
|
|
3501
|
-
<>
|
|
3502
|
-
{blockUI && (
|
|
3503
|
-
<div
|
|
3504
|
-
style={styles.overlay}
|
|
3505
|
-
aria-hidden="true"
|
|
3506
|
-
role="presentation"
|
|
3507
|
-
/>
|
|
3508
|
-
)}
|
|
3509
|
-
<div
|
|
3510
|
-
ref={modalRef}
|
|
3511
|
-
style={{
|
|
3512
|
-
...styles.modal,
|
|
3513
|
-
...responsiveStyles.modal,
|
|
3514
|
-
...modalStyle,
|
|
3515
|
-
}}
|
|
3516
|
-
role="dialog"
|
|
3517
|
-
aria-modal="true"
|
|
3518
|
-
aria-labelledby="privacy-notice-title"
|
|
3519
|
-
aria-describedby="privacy-notice-description"
|
|
3520
|
-
tabIndex={-1}
|
|
3521
|
-
>
|
|
3522
|
-
{isLoading ? (
|
|
3523
|
-
<div
|
|
3524
|
-
role="status"
|
|
3525
|
-
aria-live="polite"
|
|
3526
|
-
aria-label="Loading consent notice"
|
|
3527
|
-
style={styles.loadingContainer}
|
|
3528
|
-
>
|
|
3529
|
-
<div style={styles.loadingSpinner} aria-hidden="true"></div>
|
|
3530
|
-
<p style={{ ...styles.privacyText, ...componentTextStyle }}>
|
|
3531
|
-
Loading...
|
|
3532
|
-
</p>
|
|
3533
|
-
</div>
|
|
3534
|
-
) : fetchError ? (
|
|
3535
|
-
<div
|
|
3536
|
-
role="alert"
|
|
3537
|
-
aria-live="assertive"
|
|
3538
|
-
aria-label="Error loading consent notice"
|
|
3539
|
-
style={{
|
|
3540
|
-
...styles.content,
|
|
3541
|
-
...responsiveStyles.content,
|
|
3542
|
-
display: "flex",
|
|
3543
|
-
flexDirection: "column",
|
|
3544
|
-
alignItems: "center",
|
|
3545
|
-
justifyContent: "center",
|
|
3546
|
-
textAlign: "center",
|
|
3547
|
-
padding: "40px 20px",
|
|
3548
|
-
}}
|
|
3549
|
-
>
|
|
3550
|
-
<div
|
|
3551
|
-
style={{
|
|
3552
|
-
color: "#DC2626",
|
|
3553
|
-
padding: responsiveStyles.errorDialog.padding,
|
|
3554
|
-
borderRadius: "8px",
|
|
3555
|
-
marginBottom: responsiveStyles.errorDialog.marginBottom,
|
|
3556
|
-
width: "100%",
|
|
3557
|
-
maxWidth: responsiveStyles.errorDialog.maxWidth,
|
|
3558
|
-
position: "relative",
|
|
3559
|
-
}}
|
|
3560
|
-
>
|
|
3561
|
-
<button
|
|
3562
|
-
onClick={handleDecline}
|
|
3563
|
-
style={{
|
|
3564
|
-
position: "absolute",
|
|
3565
|
-
top: responsiveStyles.closeButton.top,
|
|
3566
|
-
right: responsiveStyles.closeButton.right,
|
|
3567
|
-
background: "none",
|
|
3568
|
-
border: "none",
|
|
3569
|
-
color: "#DC2626",
|
|
3570
|
-
cursor: "pointer",
|
|
3571
|
-
padding: responsiveStyles.closeButton.padding,
|
|
3572
|
-
display: "flex",
|
|
3573
|
-
alignItems: "center",
|
|
3574
|
-
justifyContent: "center",
|
|
3575
|
-
}}
|
|
3576
|
-
aria-label="Close error message"
|
|
3577
|
-
>
|
|
3578
|
-
<svg
|
|
3579
|
-
width={isMobile ? "14" : "16"}
|
|
3580
|
-
height={isMobile ? "14" : "16"}
|
|
3581
|
-
viewBox="0 0 16 16"
|
|
3582
|
-
fill="none"
|
|
3583
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
3584
|
-
aria-hidden="true"
|
|
3585
|
-
>
|
|
3586
|
-
<path
|
|
3587
|
-
d="M12 4L4 12M4 4L12 12"
|
|
3588
|
-
stroke="currentColor"
|
|
3589
|
-
strokeWidth="2"
|
|
3590
|
-
strokeLinecap="round"
|
|
3591
|
-
strokeLinejoin="round"
|
|
3592
|
-
/>
|
|
3593
|
-
</svg>
|
|
3594
|
-
</button>
|
|
3595
|
-
<h3
|
|
3596
|
-
style={{
|
|
3597
|
-
margin: responsiveStyles.errorTitle.margin,
|
|
3598
|
-
fontSize: responsiveStyles.errorTitle.fontSize,
|
|
3599
|
-
fontWeight: "600",
|
|
3600
|
-
}}
|
|
3601
|
-
>
|
|
3602
|
-
Error Loading Consent Notice
|
|
3603
|
-
</h3>
|
|
3604
|
-
<p
|
|
3605
|
-
style={{
|
|
3606
|
-
margin: responsiveStyles.errorMessage.margin,
|
|
3607
|
-
fontSize: responsiveStyles.errorMessage.fontSize,
|
|
3608
|
-
lineHeight: "1.5",
|
|
3609
|
-
}}
|
|
3610
|
-
>
|
|
3611
|
-
{fetchError.message ||
|
|
3612
|
-
"Failed to load consent notice. Please try again."}
|
|
3613
|
-
</p>
|
|
3614
|
-
<button
|
|
3615
|
-
onClick={fetchNotice}
|
|
3616
|
-
style={{
|
|
3617
|
-
backgroundColor: "#DC2626",
|
|
3618
|
-
color: "#FFFFFF",
|
|
3619
|
-
border: "none",
|
|
3620
|
-
padding: responsiveStyles.errorButton.padding,
|
|
3621
|
-
borderRadius: "6px",
|
|
3622
|
-
cursor: "pointer",
|
|
3623
|
-
fontSize: responsiveStyles.errorButton.fontSize,
|
|
3624
|
-
fontWeight: "500",
|
|
3625
|
-
transition: "background-color 0.2s",
|
|
3626
|
-
}}
|
|
3627
|
-
onMouseOver={(e) =>
|
|
3628
|
-
(e.currentTarget.style.backgroundColor = "#B91C1C")
|
|
3629
|
-
}
|
|
3630
|
-
onMouseOut={(e) =>
|
|
3631
|
-
(e.currentTarget.style.backgroundColor = "#DC2626")
|
|
3632
|
-
}
|
|
3633
|
-
aria-label="Refresh and retry loading consent notice"
|
|
3634
|
-
>
|
|
3635
|
-
Refresh
|
|
3636
|
-
</button>
|
|
3637
|
-
</div>
|
|
3638
|
-
</div>
|
|
3639
|
-
) : showAgeVerification ? (
|
|
3640
|
-
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
3641
|
-
<AgeVerification
|
|
3642
|
-
onYes={handleAgeVerificationYes}
|
|
3643
|
-
onNo={handleAgeVerificationNo}
|
|
3644
|
-
onClose={handleDecline}
|
|
3645
|
-
styles={styles}
|
|
3646
|
-
responsiveStyles={responsiveStyles}
|
|
3647
|
-
componentHeadingStyle={componentHeadingStyle}
|
|
3648
|
-
componentTextStyle={componentTextStyle}
|
|
3649
|
-
acceptButtonStyle={acceptButtonStyle}
|
|
3650
|
-
declineButtonStyle={declineButtonStyle}
|
|
3651
|
-
logoUrl={content?.logo_url || logo}
|
|
3652
|
-
isMobile={isMobile}
|
|
3653
|
-
/>
|
|
3654
|
-
</div>
|
|
3655
|
-
) : showGuardianForm ? (
|
|
3656
|
-
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
3657
|
-
<GuardianForm
|
|
3658
|
-
formData={guardianFormData}
|
|
3659
|
-
errors={guardianFormErrors}
|
|
3660
|
-
isSubmitting={isSubmittingGuardian}
|
|
3661
|
-
onChange={handleGuardianFormChange}
|
|
3662
|
-
onNext={handleGuardianFormNext}
|
|
3663
|
-
onDecline={handleDecline}
|
|
3664
|
-
styles={styles}
|
|
3665
|
-
responsiveStyles={responsiveStyles}
|
|
3666
|
-
settings={settings}
|
|
3667
|
-
componentHeadingStyle={componentHeadingStyle}
|
|
3668
|
-
componentTextStyle={componentTextStyle}
|
|
3669
|
-
acceptButtonStyle={acceptButtonStyle}
|
|
3670
|
-
declineButtonStyle={declineButtonStyle}
|
|
3671
|
-
logoUrl={content?.logo_url || logo}
|
|
3672
|
-
/>
|
|
3673
|
-
</div>
|
|
3674
|
-
) : showVerificationScreen ? (
|
|
3675
|
-
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
3676
|
-
<VerificationScreen
|
|
3677
|
-
onBack={handleBackToGuardianForm}
|
|
3678
|
-
onContinue={handleVerificationContinue}
|
|
3679
|
-
isInitiating={isInitiatingVerification}
|
|
3680
|
-
isPolling={isPollingStatus}
|
|
3681
|
-
isVerificationComplete={isVerificationComplete}
|
|
3682
|
-
isAutoTransitioning={isAutoTransitioning}
|
|
3683
|
-
error={verificationError}
|
|
3684
|
-
errorCode={verificationErrorCode}
|
|
3685
|
-
canRetry={canRetryVerification}
|
|
3686
|
-
styles={styles}
|
|
3687
|
-
responsiveStyles={responsiveStyles}
|
|
3688
|
-
settings={settings}
|
|
3689
|
-
componentHeadingStyle={componentHeadingStyle}
|
|
3690
|
-
componentTextStyle={componentTextStyle}
|
|
3691
|
-
acceptButtonStyle={acceptButtonStyle}
|
|
3692
|
-
declineButtonStyle={declineButtonStyle}
|
|
3693
|
-
logoUrl={content?.logo_url || logo}
|
|
3694
|
-
/>
|
|
3695
|
-
</div>
|
|
3696
|
-
) : (
|
|
3697
|
-
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
3698
|
-
{errorMessage && (
|
|
3699
|
-
<div
|
|
3700
|
-
role="alert"
|
|
3701
|
-
style={{
|
|
3702
|
-
backgroundColor: "#FEE2E2",
|
|
3703
|
-
color: "#DC2626",
|
|
3704
|
-
padding: "12px",
|
|
3705
|
-
borderRadius: "6px",
|
|
3706
|
-
marginBottom: "16px",
|
|
3707
|
-
fontSize: "14px",
|
|
3708
|
-
border: "1px solid #FCA5A5",
|
|
3709
|
-
position: "relative",
|
|
3710
|
-
display: "flex",
|
|
3711
|
-
alignItems: "center",
|
|
3712
|
-
justifyContent: "space-between",
|
|
3713
|
-
}}
|
|
3714
|
-
>
|
|
3715
|
-
<span>{errorMessage}</span>
|
|
3716
|
-
<button
|
|
3717
|
-
type="button"
|
|
3718
|
-
onClick={handleCloseError}
|
|
3719
|
-
style={{
|
|
3720
|
-
background: "none",
|
|
3721
|
-
border: "none",
|
|
3722
|
-
color: "#DC2626",
|
|
3723
|
-
cursor: "pointer",
|
|
3724
|
-
padding: "4px",
|
|
3725
|
-
marginLeft: "8px",
|
|
3726
|
-
display: "flex",
|
|
3727
|
-
alignItems: "center",
|
|
3728
|
-
justifyContent: "center",
|
|
3729
|
-
}}
|
|
3730
|
-
aria-label="Close error message"
|
|
3731
|
-
>
|
|
3732
|
-
<svg
|
|
3733
|
-
width="16"
|
|
3734
|
-
height="16"
|
|
3735
|
-
viewBox="0 0 16 16"
|
|
3736
|
-
fill="none"
|
|
3737
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
3738
|
-
aria-hidden="true"
|
|
3739
|
-
>
|
|
3740
|
-
<path
|
|
3741
|
-
d="M12 4L4 12M4 4L12 12"
|
|
3742
|
-
stroke="currentColor"
|
|
3743
|
-
strokeWidth="2"
|
|
3744
|
-
strokeLinecap="round"
|
|
3745
|
-
strokeLinejoin="round"
|
|
3746
|
-
/>
|
|
3747
|
-
</svg>
|
|
3748
|
-
</button>
|
|
3749
|
-
</div>
|
|
3750
|
-
)}
|
|
3751
|
-
<div
|
|
3752
|
-
style={{
|
|
3753
|
-
...styles.topSection,
|
|
3754
|
-
...responsiveStyles.topSection,
|
|
3755
|
-
...sectionStyle,
|
|
3756
|
-
}}
|
|
3757
|
-
>
|
|
3758
|
-
{(() => {
|
|
3759
|
-
const logoNode = (
|
|
3760
|
-
<img
|
|
3761
|
-
style={{ ...styles.logo, ...responsiveStyles.logo }}
|
|
3762
|
-
src={content?.logo_url || logo}
|
|
3763
|
-
alt="Redacto Logo"
|
|
3764
|
-
/>
|
|
3765
|
-
);
|
|
3766
|
-
const titleNode = (
|
|
3767
|
-
<h2
|
|
3768
|
-
key={`privacy-notice-title-${selectedLanguage}`}
|
|
3769
|
-
id="privacy-notice-title"
|
|
3770
|
-
style={{
|
|
3771
|
-
...styles.title,
|
|
3772
|
-
...responsiveStyles.title,
|
|
3773
|
-
...componentHeadingStyle,
|
|
3774
|
-
}}
|
|
3775
|
-
>
|
|
3776
|
-
{getTranslatedText(
|
|
3777
|
-
"notice_banner_heading",
|
|
3778
|
-
content?.notice_banner_heading || "Privacy Notice"
|
|
3779
|
-
)}
|
|
3780
|
-
</h2>
|
|
3781
|
-
);
|
|
3782
|
-
const controlsBlock = (
|
|
3783
|
-
<div style={styles.languageSelectorContainer}>
|
|
3784
|
-
<div
|
|
3785
|
-
style={{
|
|
3786
|
-
display: "flex",
|
|
3787
|
-
alignItems: "center",
|
|
3788
|
-
gap: "8px",
|
|
3789
|
-
}}
|
|
3790
|
-
>
|
|
3791
|
-
{/* Speaker icon button - only visible when TTS is available */}
|
|
3792
|
-
{isTTSAvailable && (
|
|
3793
|
-
<button
|
|
3794
|
-
onClick={toggleAudio}
|
|
3795
|
-
style={{
|
|
3796
|
-
...styles.topRight,
|
|
3797
|
-
...responsiveStyles.topRight,
|
|
3798
|
-
...languageSelectorStyle,
|
|
3799
|
-
padding: "3px 9px",
|
|
3800
|
-
display: "flex",
|
|
3801
|
-
alignItems: "center",
|
|
3802
|
-
justifyContent: "center",
|
|
3803
|
-
}}
|
|
3804
|
-
aria-label={
|
|
3805
|
-
isPlaying && !isPaused
|
|
3806
|
-
? "Pause audio playback"
|
|
3807
|
-
: isPaused
|
|
3808
|
-
? "Resume audio playback"
|
|
3809
|
-
: "Start audio playback"
|
|
3810
|
-
}
|
|
3811
|
-
title={
|
|
3812
|
-
isPlaying && !isPaused
|
|
3813
|
-
? "Pause Talkback"
|
|
3814
|
-
: isPaused
|
|
3815
|
-
? "Resume Talkback"
|
|
3816
|
-
: "Start Talkback"
|
|
3817
|
-
}
|
|
3818
|
-
>
|
|
3819
|
-
{isPlaying && !isPaused ? (
|
|
3820
|
-
// Pause icon
|
|
3821
|
-
<svg
|
|
3822
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
3823
|
-
width="16"
|
|
3824
|
-
height="16"
|
|
3825
|
-
viewBox="0 0 24 24"
|
|
3826
|
-
fill="none"
|
|
3827
|
-
stroke={
|
|
3828
|
-
settings?.button?.language?.textColor ||
|
|
3829
|
-
"currentColor"
|
|
3830
|
-
}
|
|
3831
|
-
strokeWidth="2"
|
|
3832
|
-
strokeLinecap="round"
|
|
3833
|
-
strokeLinejoin="round"
|
|
3834
|
-
aria-hidden="true"
|
|
3835
|
-
>
|
|
3836
|
-
<rect x="6" y="4" width="4" height="16" />
|
|
3837
|
-
<rect x="14" y="4" width="4" height="16" />
|
|
3838
|
-
</svg>
|
|
3839
|
-
) : (
|
|
3840
|
-
// Play/Sound icon
|
|
3841
|
-
<svg
|
|
3842
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
3843
|
-
width="16"
|
|
3844
|
-
height="16"
|
|
3845
|
-
viewBox="0 0 24 24"
|
|
3846
|
-
fill="none"
|
|
3847
|
-
stroke={
|
|
3848
|
-
settings?.button?.language?.textColor ||
|
|
3849
|
-
"currentColor"
|
|
3850
|
-
}
|
|
3851
|
-
strokeWidth="2"
|
|
3852
|
-
strokeLinecap="round"
|
|
3853
|
-
strokeLinejoin="round"
|
|
3854
|
-
aria-hidden="true"
|
|
3855
|
-
>
|
|
3856
|
-
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
3857
|
-
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
3858
|
-
</svg>
|
|
3859
|
-
)}
|
|
3860
|
-
</button>
|
|
3861
|
-
)}
|
|
3862
|
-
<div>
|
|
3863
|
-
<button
|
|
3864
|
-
ref={firstFocusableRef}
|
|
3865
|
-
style={{
|
|
3866
|
-
...styles.topRight,
|
|
3867
|
-
...responsiveStyles.topRight,
|
|
3868
|
-
...languageSelectorStyle,
|
|
3869
|
-
}}
|
|
3870
|
-
onClick={toggleLanguageDropdown}
|
|
3871
|
-
aria-expanded={isLanguageDropdownOpen}
|
|
3872
|
-
aria-haspopup="listbox"
|
|
3873
|
-
aria-controls="language-listbox"
|
|
3874
|
-
aria-label={`Select language. Current language: ${toNativeLabel(selectedLanguage)}`}
|
|
3875
|
-
>
|
|
3876
|
-
{toNativeLabel(selectedLanguage)}
|
|
3877
|
-
<svg
|
|
3878
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
3879
|
-
width="16"
|
|
3880
|
-
height="16"
|
|
3881
|
-
viewBox="0 0 16 16"
|
|
3882
|
-
fill="none"
|
|
3883
|
-
stroke={
|
|
3884
|
-
settings?.button?.language?.textColor ||
|
|
3885
|
-
"currentColor"
|
|
3886
|
-
}
|
|
3887
|
-
strokeWidth="2"
|
|
3888
|
-
strokeLinecap="round"
|
|
3889
|
-
strokeLinejoin="round"
|
|
3890
|
-
style={{ flexShrink: 0, marginLeft: "4px" }}
|
|
3891
|
-
aria-hidden="true"
|
|
3892
|
-
>
|
|
3893
|
-
<path d="M4 6l4 4 4-4" />
|
|
3894
|
-
</svg>
|
|
3895
|
-
</button>
|
|
3896
|
-
</div>
|
|
3897
|
-
</div>
|
|
3898
|
-
{isLanguageDropdownOpen && (
|
|
3899
|
-
<ul
|
|
3900
|
-
id="language-listbox"
|
|
3901
|
-
style={{
|
|
3902
|
-
...styles.languageDropdown,
|
|
3903
|
-
...languageSelectorStyle,
|
|
3904
|
-
}}
|
|
3905
|
-
role="listbox"
|
|
3906
|
-
aria-label="Select language"
|
|
3907
|
-
>
|
|
3908
|
-
{availableLanguages.map((lang: string) => (
|
|
3909
|
-
<li
|
|
3910
|
-
key={lang}
|
|
3911
|
-
style={{
|
|
3912
|
-
...styles.languageItem,
|
|
3913
|
-
...responsiveStyles.languageItem,
|
|
3914
|
-
...(selectedLanguage === lang
|
|
3915
|
-
? selectedLanguageItemStyle
|
|
3916
|
-
: languageSelectorStyle),
|
|
3917
|
-
listStyle: "none",
|
|
3918
|
-
}}
|
|
3919
|
-
onClick={() => handleLanguageSelect(lang)}
|
|
3920
|
-
role="option"
|
|
3921
|
-
aria-selected={selectedLanguage === lang}
|
|
3922
|
-
tabIndex={0}
|
|
3923
|
-
aria-label={`Select ${toNativeLabel(lang)} language`}
|
|
3924
|
-
>
|
|
3925
|
-
{toNativeLabel(lang)}
|
|
3926
|
-
</li>
|
|
3927
|
-
))}
|
|
3928
|
-
</ul>
|
|
3929
|
-
)}
|
|
3930
|
-
</div>
|
|
3931
|
-
);
|
|
3932
|
-
return isMobile ? (
|
|
3933
|
-
<>
|
|
3934
|
-
<div
|
|
3935
|
-
style={{
|
|
3936
|
-
display: "flex",
|
|
3937
|
-
alignItems: "center",
|
|
3938
|
-
gap: "10px",
|
|
3939
|
-
width: "100%",
|
|
3940
|
-
}}
|
|
3941
|
-
>
|
|
3942
|
-
{logoNode}
|
|
3943
|
-
<div
|
|
3944
|
-
style={{
|
|
3945
|
-
marginLeft: "auto",
|
|
3946
|
-
display: "flex",
|
|
3947
|
-
alignItems: "center",
|
|
3948
|
-
flexShrink: 0,
|
|
3949
|
-
}}
|
|
3950
|
-
>
|
|
3951
|
-
{controlsBlock}
|
|
3952
|
-
</div>
|
|
3953
|
-
</div>
|
|
3954
|
-
{titleNode}
|
|
3955
|
-
</>
|
|
3956
|
-
) : (
|
|
3957
|
-
<>
|
|
3958
|
-
<div style={styles.topLeft}>
|
|
3959
|
-
{logoNode}
|
|
3960
|
-
{titleNode}
|
|
3961
|
-
</div>
|
|
3962
|
-
{controlsBlock}
|
|
3963
|
-
</>
|
|
3964
|
-
);
|
|
3965
|
-
})()}
|
|
3966
|
-
</div>
|
|
3967
|
-
<div
|
|
3968
|
-
id="privacy-notice-description"
|
|
3969
|
-
style={{
|
|
3970
|
-
...styles.middleSection,
|
|
3971
|
-
...responsiveStyles.middleSection,
|
|
3972
|
-
...sectionStyle,
|
|
3973
|
-
}}
|
|
3974
|
-
>
|
|
3975
|
-
<p
|
|
3976
|
-
key={`privacy-text-${selectedLanguage}`}
|
|
3977
|
-
style={{
|
|
3978
|
-
...styles.privacyText,
|
|
3979
|
-
...responsiveStyles.privacyText,
|
|
3980
|
-
...componentTextStyle,
|
|
3981
|
-
}}
|
|
3982
|
-
>
|
|
3983
|
-
<span
|
|
3984
|
-
id="privacy-notice-text"
|
|
3985
|
-
key={`privacy-notice-text-${selectedLanguage}`}
|
|
3986
|
-
>
|
|
3987
|
-
{content
|
|
3988
|
-
? getTranslatedText("notice_text", content.notice_text)
|
|
3989
|
-
: ""}
|
|
3990
|
-
</span>
|
|
3991
|
-
<br />
|
|
3992
|
-
<span
|
|
3993
|
-
id="privacy-policy-text"
|
|
3994
|
-
key={`privacy-policy-text-${selectedLanguage}`}
|
|
3995
|
-
>
|
|
3996
|
-
{getTranslatedText(
|
|
3997
|
-
"privacy_policy_prefix_text",
|
|
3998
|
-
content?.privacy_policy_prefix_text || ""
|
|
3999
|
-
)}{" "}
|
|
4000
|
-
<a
|
|
4001
|
-
id="privacy-policy-link"
|
|
4002
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
4003
|
-
href={content?.privacy_policy_url || "#"}
|
|
4004
|
-
target="_blank"
|
|
4005
|
-
rel="noopener noreferrer"
|
|
4006
|
-
aria-label="Privacy Policy (opens in new tab)"
|
|
4007
|
-
>
|
|
4008
|
-
{getTranslatedText(
|
|
4009
|
-
"privacy_policy_anchor_text",
|
|
4010
|
-
content?.privacy_policy_anchor_text ||
|
|
4011
|
-
"Privacy Policy"
|
|
4012
|
-
)}
|
|
4013
|
-
</a>
|
|
4014
|
-
</span>
|
|
4015
|
-
</p>
|
|
4016
|
-
<h2
|
|
4017
|
-
key={`purpose-section-heading-${selectedLanguage}`}
|
|
4018
|
-
id="purpose-section-heading"
|
|
4019
|
-
style={{
|
|
4020
|
-
...styles.subTitle,
|
|
4021
|
-
...responsiveStyles.subTitle,
|
|
4022
|
-
...componentHeadingStyle,
|
|
4023
|
-
}}
|
|
4024
|
-
>
|
|
4025
|
-
{getTranslatedText(
|
|
4026
|
-
"purpose_section_heading",
|
|
4027
|
-
"Manage What You Share"
|
|
4028
|
-
)}
|
|
4029
|
-
</h2>
|
|
4030
|
-
|
|
4031
|
-
{/* Reconsent UI with visual indicators only */}
|
|
4032
|
-
{categorizedPurposes ? (
|
|
4033
|
-
<div style={styles.optionsContainer}>
|
|
4034
|
-
{/* Already Consented Purposes */}
|
|
4035
|
-
{categorizedPurposes.alreadyConsented.map((purpose) => (
|
|
4036
|
-
<PurposeItem
|
|
4037
|
-
key={`${purpose.uuid}-${selectedLanguage}`}
|
|
4038
|
-
purpose={purpose}
|
|
4039
|
-
selectedPurposes={selectedPurposes}
|
|
4040
|
-
collapsedPurposes={collapsedPurposes}
|
|
4041
|
-
selectedDataElements={selectedDataElements}
|
|
4042
|
-
settings={settings}
|
|
4043
|
-
styles={styles}
|
|
4044
|
-
responsiveStyles={responsiveStyles}
|
|
4045
|
-
onPurposeToggle={handlePurposeCheckboxChange}
|
|
4046
|
-
onPurposeCollapse={togglePurposeCollapse}
|
|
4047
|
-
onDataElementToggle={handleDataElementCheckboxChange}
|
|
4048
|
-
getTranslatedText={getTranslatedText}
|
|
4049
|
-
isAlreadyConsented={true}
|
|
4050
|
-
selectedLanguage={selectedLanguage}
|
|
4051
|
-
initialDataElementSelections={initialDataElementSelections}
|
|
4052
|
-
/>
|
|
4053
|
-
))}
|
|
4054
|
-
|
|
4055
|
-
{/* Need Consent Purposes */}
|
|
4056
|
-
{categorizedPurposes.needsConsent.map((purpose) => (
|
|
4057
|
-
<PurposeItem
|
|
4058
|
-
key={`${purpose.uuid}-${selectedLanguage}`}
|
|
4059
|
-
purpose={purpose}
|
|
4060
|
-
selectedPurposes={selectedPurposes}
|
|
4061
|
-
collapsedPurposes={collapsedPurposes}
|
|
4062
|
-
selectedDataElements={selectedDataElements}
|
|
4063
|
-
settings={settings}
|
|
4064
|
-
styles={styles}
|
|
4065
|
-
responsiveStyles={responsiveStyles}
|
|
4066
|
-
onPurposeToggle={handlePurposeCheckboxChange}
|
|
4067
|
-
onPurposeCollapse={togglePurposeCollapse}
|
|
4068
|
-
onDataElementToggle={handleDataElementCheckboxChange}
|
|
4069
|
-
getTranslatedText={getTranslatedText}
|
|
4070
|
-
isAlreadyConsented={false}
|
|
4071
|
-
selectedLanguage={selectedLanguage}
|
|
4072
|
-
/>
|
|
4073
|
-
))}
|
|
4074
|
-
</div>
|
|
4075
|
-
) : (
|
|
4076
|
-
/* Regular consent UI */
|
|
4077
|
-
<div style={styles.optionsContainer}>
|
|
4078
|
-
{content?.purposes?.map((purpose) => (
|
|
4079
|
-
<PurposeItem
|
|
4080
|
-
key={`${purpose.uuid}-${selectedLanguage}`}
|
|
4081
|
-
purpose={purpose}
|
|
4082
|
-
selectedPurposes={selectedPurposes}
|
|
4083
|
-
collapsedPurposes={collapsedPurposes}
|
|
4084
|
-
selectedDataElements={selectedDataElements}
|
|
4085
|
-
settings={settings}
|
|
4086
|
-
styles={styles}
|
|
4087
|
-
responsiveStyles={responsiveStyles}
|
|
4088
|
-
onPurposeToggle={handlePurposeCheckboxChange}
|
|
4089
|
-
onPurposeCollapse={togglePurposeCollapse}
|
|
4090
|
-
onDataElementToggle={handleDataElementCheckboxChange}
|
|
4091
|
-
getTranslatedText={getTranslatedText}
|
|
4092
|
-
isAlreadyConsented={isFullyConsentedReviewMode}
|
|
4093
|
-
selectedLanguage={selectedLanguage}
|
|
4094
|
-
initialDataElementSelections={initialDataElementSelections}
|
|
4095
|
-
/>
|
|
4096
|
-
))}
|
|
4097
|
-
</div>
|
|
4098
|
-
)}
|
|
4099
|
-
|
|
4100
|
-
{(content?.privacy_center_url || content?.dpo_info) && (
|
|
4101
|
-
<div
|
|
4102
|
-
style={{
|
|
4103
|
-
...styles.privacyText,
|
|
4104
|
-
...responsiveStyles.privacyText,
|
|
4105
|
-
...componentTextStyle,
|
|
4106
|
-
fontSize: isMobile ? "13px" : "14px",
|
|
4107
|
-
lineHeight: "1.4",
|
|
4108
|
-
borderTop: "1px solid rgba(152, 162, 179, 0.3)",
|
|
4109
|
-
paddingTop: "12px",
|
|
4110
|
-
marginTop: "12px",
|
|
4111
|
-
}}
|
|
4112
|
-
>
|
|
4113
|
-
<p style={{ margin: 0, opacity: 0.6 }}>
|
|
4114
|
-
{content.privacy_center_url && (
|
|
4115
|
-
<>
|
|
4116
|
-
{content.additional_text && (
|
|
4117
|
-
<span id="additional-text">
|
|
4118
|
-
{getTranslatedText(
|
|
4119
|
-
"additional_text",
|
|
4120
|
-
content.additional_text
|
|
4121
|
-
)}
|
|
4122
|
-
</span>
|
|
4123
|
-
)}{" "}
|
|
4124
|
-
<a
|
|
4125
|
-
id="privacy-center-link"
|
|
4126
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
4127
|
-
href={content.privacy_center_url}
|
|
4128
|
-
target="_blank"
|
|
4129
|
-
rel="noopener noreferrer"
|
|
4130
|
-
>
|
|
4131
|
-
{getTranslatedText(
|
|
4132
|
-
"privacy_center_anchor_text",
|
|
4133
|
-
content.privacy_center_anchor_text || "Privacy Center"
|
|
4134
|
-
)}
|
|
4135
|
-
</a>
|
|
4136
|
-
.
|
|
4137
|
-
</>
|
|
4138
|
-
)}
|
|
4139
|
-
{content.dpo_info && (
|
|
4140
|
-
<>
|
|
4141
|
-
{content.privacy_center_url && <br />}
|
|
4142
|
-
<span id="dpo-grievance-text">
|
|
4143
|
-
{getTranslatedDpoText(
|
|
4144
|
-
"grievance_text",
|
|
4145
|
-
content.dpo_info.grievance_text || ""
|
|
4146
|
-
)}
|
|
4147
|
-
</span>{" "}
|
|
4148
|
-
{content.dpo_info.grievance_url && (
|
|
4149
|
-
<a
|
|
4150
|
-
id="dpo-grievance-link"
|
|
4151
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
4152
|
-
href={content.dpo_info.grievance_url}
|
|
4153
|
-
target="_blank"
|
|
4154
|
-
rel="noopener noreferrer"
|
|
4155
|
-
>
|
|
4156
|
-
{getTranslatedDpoText(
|
|
4157
|
-
"grievance_anchor_text",
|
|
4158
|
-
content.dpo_info.grievance_anchor_text || "click here"
|
|
4159
|
-
)}
|
|
4160
|
-
</a>
|
|
4161
|
-
)}
|
|
4162
|
-
{content.dpo_info.grievance_email && (
|
|
4163
|
-
<>
|
|
4164
|
-
{" "}
|
|
4165
|
-
<span id="dpo-grievance-email-connector">
|
|
4166
|
-
{getTranslatedDpoText(
|
|
4167
|
-
"grievance_email_connector_text",
|
|
4168
|
-
content.dpo_info.grievance_email_connector_text || "or email to"
|
|
4169
|
-
)}
|
|
4170
|
-
</span>
|
|
4171
|
-
{" "}
|
|
4172
|
-
<a
|
|
4173
|
-
id="dpo-grievance-email"
|
|
4174
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
4175
|
-
href={`mailto:${content.dpo_info.grievance_email}`}
|
|
4176
|
-
>
|
|
4177
|
-
{content.dpo_info.grievance_email}
|
|
4178
|
-
</a>
|
|
4179
|
-
</>
|
|
4180
|
-
)}
|
|
4181
|
-
<br />
|
|
4182
|
-
<span id="dpo-dp-board-text">
|
|
4183
|
-
{getTranslatedDpoText(
|
|
4184
|
-
"dp_board_text",
|
|
4185
|
-
content.dpo_info.dp_board_text || ""
|
|
4186
|
-
)}
|
|
4187
|
-
</span>{" "}
|
|
4188
|
-
{content.dpo_info.dp_board_url && (
|
|
4189
|
-
<a
|
|
4190
|
-
id="dpo-dp-board-link"
|
|
4191
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
4192
|
-
href={content.dpo_info.dp_board_url}
|
|
4193
|
-
target="_blank"
|
|
4194
|
-
rel="noopener noreferrer"
|
|
4195
|
-
>
|
|
4196
|
-
{getTranslatedDpoText(
|
|
4197
|
-
"dp_board_anchor_text",
|
|
4198
|
-
content.dpo_info.dp_board_anchor_text || "here"
|
|
4199
|
-
)}
|
|
4200
|
-
</a>
|
|
4201
|
-
)}
|
|
4202
|
-
<br />
|
|
4203
|
-
<span id="dpo-dpo-text">
|
|
4204
|
-
{getTranslatedDpoText(
|
|
4205
|
-
"dpo_text",
|
|
4206
|
-
content.dpo_info.dpo_text || ""
|
|
4207
|
-
)}
|
|
4208
|
-
</span>{" "}
|
|
4209
|
-
{content.dpo_info.dpo_email && (
|
|
4210
|
-
<a
|
|
4211
|
-
id="dpo-dpo-link"
|
|
4212
|
-
style={{ ...styles.link, ...linkStyle }}
|
|
4213
|
-
href={`mailto:${content.dpo_info.dpo_email}`}
|
|
4214
|
-
>
|
|
4215
|
-
{getTranslatedDpoText(
|
|
4216
|
-
"dpo_anchor_text",
|
|
4217
|
-
content.dpo_info.dpo_anchor_text || "Click here"
|
|
4218
|
-
)}
|
|
4219
|
-
</a>
|
|
4220
|
-
)}
|
|
4221
|
-
</>
|
|
4222
|
-
)}
|
|
4223
|
-
</p>
|
|
4224
|
-
</div>
|
|
4225
|
-
)}
|
|
4226
|
-
</div>
|
|
4227
|
-
<div
|
|
4228
|
-
style={{
|
|
4229
|
-
...styles.bottomSection,
|
|
4230
|
-
...responsiveStyles.bottomSection,
|
|
4231
|
-
...sectionStyle,
|
|
4232
|
-
}}
|
|
4233
|
-
>
|
|
4234
|
-
{isFullyConsentedReviewMode ? (
|
|
4235
|
-
<button
|
|
4236
|
-
ref={lastFocusableRef}
|
|
4237
|
-
id="review-mode-continue-button"
|
|
4238
|
-
style={{
|
|
4239
|
-
...styles.button,
|
|
4240
|
-
...responsiveStyles.button,
|
|
4241
|
-
...styles.acceptButton,
|
|
4242
|
-
...acceptButtonStyle,
|
|
4243
|
-
}}
|
|
4244
|
-
onClick={() => onAccept?.()}
|
|
4245
|
-
aria-label={reviewModeButtonText}
|
|
4246
|
-
>
|
|
4247
|
-
{reviewModeButtonText}
|
|
4248
|
-
</button>
|
|
4249
|
-
) : (
|
|
4250
|
-
<>
|
|
4251
|
-
<button
|
|
4252
|
-
ref={lastFocusableRef}
|
|
4253
|
-
id="confirm-button-text"
|
|
4254
|
-
style={{
|
|
4255
|
-
...styles.button,
|
|
4256
|
-
...responsiveStyles.button,
|
|
4257
|
-
...styles.acceptButton,
|
|
4258
|
-
...acceptButtonStyle,
|
|
4259
|
-
opacity: acceptDisabled ? 0.5 : 1,
|
|
4260
|
-
}}
|
|
4261
|
-
onClick={handleAccept}
|
|
4262
|
-
disabled={acceptDisabled || isSubmittingDecline}
|
|
4263
|
-
aria-label={getTranslatedText(
|
|
4264
|
-
"confirm_button_text",
|
|
4265
|
-
content?.confirm_button_text || "Accept"
|
|
4266
|
-
)}
|
|
4267
|
-
>
|
|
4268
|
-
{isSubmittingAccept
|
|
4269
|
-
? "Processing..."
|
|
4270
|
-
: getTranslatedText(
|
|
4271
|
-
"confirm_button_text",
|
|
4272
|
-
content?.confirm_button_text || "Accept"
|
|
4273
|
-
)}
|
|
4274
|
-
</button>
|
|
4275
|
-
<button
|
|
4276
|
-
id="decline-button-text"
|
|
4277
|
-
style={{
|
|
4278
|
-
...styles.button,
|
|
4279
|
-
...responsiveStyles.button,
|
|
4280
|
-
...styles.cancelButton,
|
|
4281
|
-
...declineButtonStyle,
|
|
4282
|
-
opacity: isSubmittingAccept ? 0.5 : 1,
|
|
4283
|
-
}}
|
|
4284
|
-
onClick={handleDecline}
|
|
4285
|
-
disabled={isSubmittingDecline || isSubmittingAccept}
|
|
4286
|
-
aria-label={getTranslatedText(
|
|
4287
|
-
"decline_button_text",
|
|
4288
|
-
content?.decline_button_text || "Decline"
|
|
4289
|
-
)}
|
|
4290
|
-
>
|
|
4291
|
-
{isSubmittingDecline
|
|
4292
|
-
? "Processing..."
|
|
4293
|
-
: getTranslatedText(
|
|
4294
|
-
"decline_button_text",
|
|
4295
|
-
content?.decline_button_text || "Decline"
|
|
4296
|
-
)}
|
|
4297
|
-
</button>
|
|
4298
|
-
</>
|
|
4299
|
-
)}
|
|
4300
|
-
</div>
|
|
4301
|
-
</div>
|
|
4302
|
-
)}
|
|
4303
|
-
</div>
|
|
4304
|
-
</>
|
|
4305
|
-
)}
|
|
4306
|
-
</>
|
|
4307
|
-
);
|
|
4308
|
-
};
|