@redacto.io/consent-sdk-react 10.0.0-beta.11 → 10.0.0-beta.12

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