@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.
Files changed (264) hide show
  1. package/NOTICE.md +17 -0
  2. package/README.md +141 -3
  3. package/dist/chunk-FEGTW73Q.mjs +182 -0
  4. package/dist/fonts.d.mts +35 -0
  5. package/dist/fonts.d.ts +35 -0
  6. package/dist/fonts.js +183 -0
  7. package/dist/fonts.mjs +10 -0
  8. package/dist/index.d.mts +12 -1
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.js +724 -365
  11. package/dist/index.mjs +346 -201
  12. package/dist/montserrat.generated-NBTECYRW.mjs +22 -0
  13. package/dist/privacy-center.d.mts +0 -1
  14. package/dist/privacy-center.d.ts +0 -1
  15. package/dist/privacy-center.js +2 -66
  16. package/dist/privacy-center.mjs +2 -66
  17. package/dist/types-6Aj7faxL.d.mts +57 -0
  18. package/dist/types-6Aj7faxL.d.ts +57 -0
  19. package/package.json +10 -1
  20. package/.turbo/turbo-build.log +0 -22
  21. package/.turbo/turbo-test.log +0 -203
  22. package/.turbo/turbo-verify-pack.log +0 -6
  23. package/CHANGELOG.md +0 -353
  24. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +0 -934
  25. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +0 -2160
  26. package/src/RedactoNoticeAssisted/api/index.ts +0 -275
  27. package/src/RedactoNoticeAssisted/api/types.ts +0 -182
  28. package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
  29. package/src/RedactoNoticeAssisted/constants.ts +0 -51
  30. package/src/RedactoNoticeAssisted/i18n.ts +0 -1408
  31. package/src/RedactoNoticeAssisted/index.ts +0 -1
  32. package/src/RedactoNoticeAssisted/injectStyles.ts +0 -251
  33. package/src/RedactoNoticeAssisted/styles.ts +0 -730
  34. package/src/RedactoNoticeAssisted/types.ts +0 -26
  35. package/src/RedactoNoticeAssisted/useMediaQuery.ts +0 -36
  36. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +0 -1152
  37. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +0 -4308
  38. package/src/RedactoNoticeConsent/api/index.ts +0 -542
  39. package/src/RedactoNoticeConsent/api/sandbox.ts +0 -150
  40. package/src/RedactoNoticeConsent/api/types.ts +0 -515
  41. package/src/RedactoNoticeConsent/assets/redacto-logo.png +0 -0
  42. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +0 -602
  43. package/src/RedactoNoticeConsent/components/index.ts +0 -7
  44. package/src/RedactoNoticeConsent/constants.ts +0 -20
  45. package/src/RedactoNoticeConsent/index.ts +0 -1
  46. package/src/RedactoNoticeConsent/injectStyles.ts +0 -151
  47. package/src/RedactoNoticeConsent/language-codes.ts +0 -95
  48. package/src/RedactoNoticeConsent/services/storage.ts +0 -122
  49. package/src/RedactoNoticeConsent/styles.ts +0 -409
  50. package/src/RedactoNoticeConsent/types.ts +0 -80
  51. package/src/RedactoNoticeConsent/useMediaQuery.ts +0 -36
  52. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.test.tsx +0 -369
  53. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -687
  54. package/src/RedactoNoticeConsentInline/api/index.ts +0 -106
  55. package/src/RedactoNoticeConsentInline/api/types.ts +0 -183
  56. package/src/RedactoNoticeConsentInline/assets/redacto-logo.png +0 -0
  57. package/src/RedactoNoticeConsentInline/index.ts +0 -1
  58. package/src/RedactoNoticeConsentInline/injectStyles.ts +0 -40
  59. package/src/RedactoNoticeConsentInline/styles.ts +0 -397
  60. package/src/RedactoNoticeConsentInline/types.ts +0 -51
  61. package/src/RedactoNoticeConsentInline/useMediaQuery.ts +0 -36
  62. package/src/RedactoPrivacyCenter/NotificationsPage.tsx +0 -182
  63. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +0 -19
  64. package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +0 -156
  65. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +0 -17
  66. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +0 -79
  67. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +0 -36
  68. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +0 -177
  69. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +0 -26
  70. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +0 -93
  71. package/src/RedactoPrivacyCenter/api/actions.ts +0 -695
  72. package/src/RedactoPrivacyCenter/api/client.ts +0 -1003
  73. package/src/RedactoPrivacyCenter/api/config.ts +0 -33
  74. package/src/RedactoPrivacyCenter/api/fetcher.ts +0 -275
  75. package/src/RedactoPrivacyCenter/api/orgBranding.ts +0 -16
  76. package/src/RedactoPrivacyCenter/api/upload.ts +0 -93
  77. package/src/RedactoPrivacyCenter/assets/character.svg +0 -31
  78. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +0 -9
  79. package/src/RedactoPrivacyCenter/assets/greenTick.svg +0 -4
  80. package/src/RedactoPrivacyCenter/assets/mail.svg +0 -3
  81. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +0 -9
  82. package/src/RedactoPrivacyCenter/assets/sentImg.svg +0 -9
  83. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +0 -9
  84. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +0 -252
  85. package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +0 -239
  86. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +0 -268
  87. package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +0 -218
  88. package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +0 -205
  89. package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +0 -107
  90. package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +0 -100
  91. package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +0 -339
  92. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +0 -245
  93. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +0 -28
  94. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +0 -40
  95. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +0 -62
  96. package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +0 -36
  97. package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +0 -38
  98. package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +0 -31
  99. package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +0 -79
  100. package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +0 -25
  101. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +0 -96
  102. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +0 -97
  103. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +0 -156
  104. package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +0 -171
  105. package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +0 -23
  106. package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +0 -116
  107. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +0 -43
  108. package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +0 -71
  109. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +0 -469
  110. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +0 -426
  111. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +0 -1206
  112. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +0 -218
  113. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +0 -138
  114. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +0 -7
  115. package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +0 -179
  116. package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +0 -318
  117. package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +0 -333
  118. package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +0 -102
  119. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +0 -159
  120. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +0 -189
  121. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +0 -118
  122. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +0 -66
  123. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +0 -222
  124. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +0 -331
  125. package/src/RedactoPrivacyCenter/components/Form.tsx +0 -1821
  126. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +0 -174
  127. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +0 -337
  128. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +0 -334
  129. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +0 -173
  130. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +0 -18
  131. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +0 -54
  132. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +0 -57
  133. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +0 -19
  134. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +0 -92
  135. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +0 -20
  136. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +0 -58
  137. package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +0 -10
  138. package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +0 -112
  139. package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +0 -27
  140. package/src/RedactoPrivacyCenter/components/Pagination.tsx +0 -140
  141. package/src/RedactoPrivacyCenter/components/PrivacyCenterEmptyState.tsx +0 -76
  142. package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +0 -97
  143. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +0 -454
  144. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +0 -270
  145. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +0 -95
  146. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +0 -195
  147. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +0 -25
  148. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -21
  149. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +0 -159
  150. package/src/RedactoPrivacyCenter/components/constant.ts +0 -52
  151. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +0 -155
  152. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +0 -203
  153. package/src/RedactoPrivacyCenter/components/types.ts +0 -123
  154. package/src/RedactoPrivacyCenter/components/utils.tsx +0 -10
  155. package/src/RedactoPrivacyCenter/config/routes.ts +0 -9
  156. package/src/RedactoPrivacyCenter/config/table.ts +0 -25
  157. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +0 -252
  158. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +0 -47
  159. package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +0 -52
  160. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +0 -92
  161. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +0 -181
  162. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +0 -11
  163. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +0 -96
  164. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +0 -30
  165. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +0 -80
  166. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +0 -15
  167. package/src/RedactoPrivacyCenter/context/types.ts +0 -57
  168. package/src/RedactoPrivacyCenter/lib/api.ts +0 -759
  169. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +0 -67
  170. package/src/RedactoPrivacyCenter/lib/constants.ts +0 -283
  171. package/src/RedactoPrivacyCenter/lib/i18n.ts +0 -81
  172. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -102
  173. package/src/RedactoPrivacyCenter/lib/queries.ts +0 -141
  174. package/src/RedactoPrivacyCenter/lib/sandbox.ts +0 -104
  175. package/src/RedactoPrivacyCenter/lib/types.ts +0 -896
  176. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +0 -37
  177. package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +0 -127
  178. package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +0 -85
  179. package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +0 -109
  180. package/src/RedactoPrivacyCenter/lib/utils.ts +0 -798
  181. package/src/RedactoPrivacyCenter/locales/as/translation.json +0 -342
  182. package/src/RedactoPrivacyCenter/locales/bn/translation.json +0 -342
  183. package/src/RedactoPrivacyCenter/locales/brx/translation.json +0 -342
  184. package/src/RedactoPrivacyCenter/locales/doi/translation.json +0 -342
  185. package/src/RedactoPrivacyCenter/locales/en/translation.json +0 -357
  186. package/src/RedactoPrivacyCenter/locales/gom/translation.json +0 -342
  187. package/src/RedactoPrivacyCenter/locales/gu/translation.json +0 -342
  188. package/src/RedactoPrivacyCenter/locales/hi/translation.json +0 -345
  189. package/src/RedactoPrivacyCenter/locales/kn/translation.json +0 -342
  190. package/src/RedactoPrivacyCenter/locales/ks/translation.json +0 -342
  191. package/src/RedactoPrivacyCenter/locales/mai/translation.json +0 -342
  192. package/src/RedactoPrivacyCenter/locales/ml/translation.json +0 -342
  193. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +0 -342
  194. package/src/RedactoPrivacyCenter/locales/mr/translation.json +0 -342
  195. package/src/RedactoPrivacyCenter/locales/ne/translation.json +0 -342
  196. package/src/RedactoPrivacyCenter/locales/or/translation.json +0 -342
  197. package/src/RedactoPrivacyCenter/locales/pa/translation.json +0 -342
  198. package/src/RedactoPrivacyCenter/locales/sa/translation.json +0 -342
  199. package/src/RedactoPrivacyCenter/locales/sat/translation.json +0 -342
  200. package/src/RedactoPrivacyCenter/locales/sd/translation.json +0 -342
  201. package/src/RedactoPrivacyCenter/locales/ta/translation.json +0 -342
  202. package/src/RedactoPrivacyCenter/locales/te/translation.json +0 -342
  203. package/src/RedactoPrivacyCenter/locales/ur/translation.json +0 -342
  204. package/src/RedactoPrivacyCenter/styles/applyBrandingTheme.ts +0 -66
  205. package/src/RedactoPrivacyCenter/styles/base.css +0 -117
  206. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +0 -3108
  207. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +0 -831
  208. package/src/RedactoPrivacyCenter/styles/responsive.ts +0 -138
  209. package/src/RedactoPrivacyCenter/styles/variables.css +0 -96
  210. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +0 -387
  211. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +0 -468
  212. package/src/RedactoPrivacyCenter/table/index.ts +0 -2
  213. package/src/RedactoPrivacyCenter/types/images.d.ts +0 -29
  214. package/src/RedactoPrivacyCenter/ui/Footer.tsx +0 -69
  215. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +0 -54
  216. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +0 -93
  217. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +0 -47
  218. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +0 -83
  219. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +0 -93
  220. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +0 -48
  221. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +0 -77
  222. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +0 -55
  223. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +0 -107
  224. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +0 -35
  225. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +0 -132
  226. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +0 -31
  227. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +0 -106
  228. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +0 -206
  229. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +0 -433
  230. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +0 -149
  231. package/src/RedactoPrivacyCenter/ui/PCText.tsx +0 -41
  232. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +0 -43
  233. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +0 -37
  234. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +0 -121
  235. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +0 -25
  236. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +0 -53
  237. package/src/RedactoPrivacyCenter/ui/index.ts +0 -18
  238. package/src/RedactoPrivacyCenter/utils/cn.ts +0 -28
  239. package/src/RedactoPrivacyCenter/utils/date.ts +0 -48
  240. package/src/RedactoPrivacyCenter/utils/status.ts +0 -111
  241. package/src/RedactoPrivacyCenter/utils/toast.ts +0 -130
  242. package/src/index.ts +0 -19
  243. package/src/privacy-center.ts +0 -113
  244. package/src/shared/api-errors.ts +0 -180
  245. package/src/shared/notice-consent-status.ts +0 -190
  246. package/src/shared/sandbox.ts +0 -6
  247. package/src/types/css.d.ts +0 -4
  248. package/src/types/images.d.ts +0 -19
  249. package/src/types/react-jsx.d.ts +0 -26
  250. package/tests/DataAvailabilityContext.test.tsx +0 -123
  251. package/tests/Form.test.tsx +0 -1424
  252. package/tests/ManageConsentErrorMapping.test.tsx +0 -85
  253. package/tests/ProductSelectionSection.test.tsx +0 -328
  254. package/tests/RequestSubmit.test.tsx +0 -135
  255. package/tests/RevocablePurpose.test.tsx +0 -146
  256. package/tests/RevokeWarningMessage.test.tsx +0 -151
  257. package/tests/consentClient.test.ts +0 -217
  258. package/tests/manageConsentServer.test.ts +0 -38
  259. package/tests/mocks.ts +0 -257
  260. package/tests/resolveContactFromJwt.test.ts +0 -44
  261. package/tests/setup.ts +0 -16
  262. package/tsconfig.json +0 -16
  263. package/tsup.config.ts +0 -31
  264. 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
- };