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

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