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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/NOTICE.md +17 -0
  2. package/README.md +67 -3
  3. package/dist/chunk-FEGTW73Q.mjs +182 -0
  4. package/dist/fonts.d.mts +35 -0
  5. package/dist/fonts.d.ts +35 -0
  6. package/dist/fonts.js +183 -0
  7. package/dist/fonts.mjs +10 -0
  8. package/dist/index.d.mts +12 -1
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.js +613 -249
  11. package/dist/index.mjs +235 -85
  12. package/dist/montserrat.generated-NBTECYRW.mjs +22 -0
  13. package/dist/types-6Aj7faxL.d.mts +57 -0
  14. package/dist/types-6Aj7faxL.d.ts +57 -0
  15. package/package.json +10 -1
  16. package/.turbo/turbo-build.log +0 -22
  17. package/.turbo/turbo-test.log +0 -203
  18. package/.turbo/turbo-verify-pack.log +0 -6
  19. package/CHANGELOG.md +0 -383
  20. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +0 -934
  21. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +0 -2160
  22. package/src/RedactoNoticeAssisted/api/index.ts +0 -275
  23. package/src/RedactoNoticeAssisted/api/types.ts +0 -182
  24. package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
  25. package/src/RedactoNoticeAssisted/constants.ts +0 -51
  26. package/src/RedactoNoticeAssisted/i18n.ts +0 -1408
  27. package/src/RedactoNoticeAssisted/index.ts +0 -1
  28. package/src/RedactoNoticeAssisted/injectStyles.ts +0 -251
  29. package/src/RedactoNoticeAssisted/styles.ts +0 -730
  30. package/src/RedactoNoticeAssisted/types.ts +0 -26
  31. package/src/RedactoNoticeAssisted/useMediaQuery.ts +0 -36
  32. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +0 -1186
  33. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +0 -4299
  34. package/src/RedactoNoticeConsent/api/index.ts +0 -486
  35. package/src/RedactoNoticeConsent/api/sandbox.ts +0 -150
  36. package/src/RedactoNoticeConsent/api/types.ts +0 -505
  37. package/src/RedactoNoticeConsent/assets/redacto-logo.png +0 -0
  38. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +0 -602
  39. package/src/RedactoNoticeConsent/components/index.ts +0 -7
  40. package/src/RedactoNoticeConsent/constants.ts +0 -20
  41. package/src/RedactoNoticeConsent/index.ts +0 -1
  42. package/src/RedactoNoticeConsent/injectStyles.ts +0 -151
  43. package/src/RedactoNoticeConsent/language-codes.ts +0 -95
  44. package/src/RedactoNoticeConsent/services/storage.ts +0 -122
  45. package/src/RedactoNoticeConsent/styles.ts +0 -409
  46. package/src/RedactoNoticeConsent/types.ts +0 -80
  47. package/src/RedactoNoticeConsent/useMediaQuery.ts +0 -36
  48. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.test.tsx +0 -369
  49. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -709
  50. package/src/RedactoNoticeConsentInline/api/index.ts +0 -115
  51. package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +0 -72
  52. package/src/RedactoNoticeConsentInline/api/types.ts +0 -185
  53. package/src/RedactoNoticeConsentInline/assets/redacto-logo.png +0 -0
  54. package/src/RedactoNoticeConsentInline/index.ts +0 -1
  55. package/src/RedactoNoticeConsentInline/injectStyles.ts +0 -40
  56. package/src/RedactoNoticeConsentInline/styles.ts +0 -397
  57. package/src/RedactoNoticeConsentInline/types.ts +0 -51
  58. package/src/RedactoNoticeConsentInline/useMediaQuery.ts +0 -36
  59. package/src/RedactoPrivacyCenter/NotificationsPage.tsx +0 -182
  60. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +0 -19
  61. package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +0 -156
  62. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +0 -17
  63. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +0 -79
  64. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +0 -36
  65. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +0 -177
  66. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +0 -26
  67. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +0 -93
  68. package/src/RedactoPrivacyCenter/api/actions.ts +0 -682
  69. package/src/RedactoPrivacyCenter/api/client.ts +0 -1003
  70. package/src/RedactoPrivacyCenter/api/config.ts +0 -33
  71. package/src/RedactoPrivacyCenter/api/fetcher.ts +0 -275
  72. package/src/RedactoPrivacyCenter/api/orgBranding.ts +0 -16
  73. package/src/RedactoPrivacyCenter/api/upload.ts +0 -93
  74. package/src/RedactoPrivacyCenter/assets/character.svg +0 -31
  75. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +0 -9
  76. package/src/RedactoPrivacyCenter/assets/greenTick.svg +0 -4
  77. package/src/RedactoPrivacyCenter/assets/mail.svg +0 -3
  78. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +0 -9
  79. package/src/RedactoPrivacyCenter/assets/sentImg.svg +0 -9
  80. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +0 -9
  81. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +0 -252
  82. package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +0 -239
  83. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +0 -268
  84. package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +0 -218
  85. package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +0 -205
  86. package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +0 -107
  87. package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +0 -100
  88. package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +0 -339
  89. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +0 -245
  90. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +0 -28
  91. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +0 -40
  92. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +0 -62
  93. package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +0 -36
  94. package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +0 -38
  95. package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +0 -31
  96. package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +0 -79
  97. package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +0 -25
  98. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +0 -96
  99. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +0 -97
  100. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +0 -156
  101. package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +0 -171
  102. package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +0 -23
  103. package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +0 -116
  104. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +0 -43
  105. package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +0 -71
  106. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +0 -469
  107. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +0 -426
  108. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +0 -1199
  109. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +0 -201
  110. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +0 -138
  111. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +0 -7
  112. package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +0 -179
  113. package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +0 -318
  114. package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +0 -333
  115. package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +0 -102
  116. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +0 -159
  117. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +0 -189
  118. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +0 -118
  119. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +0 -66
  120. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +0 -222
  121. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +0 -331
  122. package/src/RedactoPrivacyCenter/components/Form.tsx +0 -1821
  123. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +0 -174
  124. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +0 -337
  125. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +0 -334
  126. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +0 -173
  127. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +0 -18
  128. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +0 -54
  129. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +0 -57
  130. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +0 -19
  131. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +0 -92
  132. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +0 -20
  133. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +0 -58
  134. package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +0 -10
  135. package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +0 -112
  136. package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +0 -27
  137. package/src/RedactoPrivacyCenter/components/Pagination.tsx +0 -140
  138. package/src/RedactoPrivacyCenter/components/PrivacyCenterEmptyState.tsx +0 -76
  139. package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +0 -97
  140. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +0 -454
  141. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +0 -270
  142. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +0 -95
  143. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +0 -195
  144. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +0 -25
  145. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -21
  146. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +0 -159
  147. package/src/RedactoPrivacyCenter/components/constant.ts +0 -52
  148. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +0 -155
  149. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +0 -203
  150. package/src/RedactoPrivacyCenter/components/types.ts +0 -123
  151. package/src/RedactoPrivacyCenter/components/utils.tsx +0 -10
  152. package/src/RedactoPrivacyCenter/config/routes.ts +0 -9
  153. package/src/RedactoPrivacyCenter/config/table.ts +0 -25
  154. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +0 -252
  155. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +0 -47
  156. package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +0 -52
  157. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +0 -92
  158. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +0 -181
  159. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +0 -11
  160. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +0 -96
  161. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +0 -30
  162. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +0 -80
  163. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +0 -15
  164. package/src/RedactoPrivacyCenter/context/types.ts +0 -57
  165. package/src/RedactoPrivacyCenter/lib/api.ts +0 -759
  166. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +0 -67
  167. package/src/RedactoPrivacyCenter/lib/constants.ts +0 -283
  168. package/src/RedactoPrivacyCenter/lib/i18n.ts +0 -81
  169. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -102
  170. package/src/RedactoPrivacyCenter/lib/queries.ts +0 -141
  171. package/src/RedactoPrivacyCenter/lib/sandbox.ts +0 -104
  172. package/src/RedactoPrivacyCenter/lib/types.ts +0 -892
  173. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +0 -37
  174. package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +0 -127
  175. package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +0 -85
  176. package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +0 -109
  177. package/src/RedactoPrivacyCenter/lib/utils.ts +0 -798
  178. package/src/RedactoPrivacyCenter/locales/as/translation.json +0 -340
  179. package/src/RedactoPrivacyCenter/locales/bn/translation.json +0 -340
  180. package/src/RedactoPrivacyCenter/locales/brx/translation.json +0 -340
  181. package/src/RedactoPrivacyCenter/locales/doi/translation.json +0 -340
  182. package/src/RedactoPrivacyCenter/locales/en/translation.json +0 -355
  183. package/src/RedactoPrivacyCenter/locales/gom/translation.json +0 -340
  184. package/src/RedactoPrivacyCenter/locales/gu/translation.json +0 -340
  185. package/src/RedactoPrivacyCenter/locales/hi/translation.json +0 -343
  186. package/src/RedactoPrivacyCenter/locales/kn/translation.json +0 -340
  187. package/src/RedactoPrivacyCenter/locales/ks/translation.json +0 -340
  188. package/src/RedactoPrivacyCenter/locales/mai/translation.json +0 -340
  189. package/src/RedactoPrivacyCenter/locales/ml/translation.json +0 -340
  190. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +0 -340
  191. package/src/RedactoPrivacyCenter/locales/mr/translation.json +0 -340
  192. package/src/RedactoPrivacyCenter/locales/ne/translation.json +0 -340
  193. package/src/RedactoPrivacyCenter/locales/or/translation.json +0 -340
  194. package/src/RedactoPrivacyCenter/locales/pa/translation.json +0 -340
  195. package/src/RedactoPrivacyCenter/locales/sa/translation.json +0 -340
  196. package/src/RedactoPrivacyCenter/locales/sat/translation.json +0 -340
  197. package/src/RedactoPrivacyCenter/locales/sd/translation.json +0 -340
  198. package/src/RedactoPrivacyCenter/locales/ta/translation.json +0 -340
  199. package/src/RedactoPrivacyCenter/locales/te/translation.json +0 -340
  200. package/src/RedactoPrivacyCenter/locales/ur/translation.json +0 -340
  201. package/src/RedactoPrivacyCenter/styles/applyBrandingTheme.ts +0 -66
  202. package/src/RedactoPrivacyCenter/styles/base.css +0 -117
  203. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +0 -3108
  204. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +0 -831
  205. package/src/RedactoPrivacyCenter/styles/responsive.ts +0 -138
  206. package/src/RedactoPrivacyCenter/styles/variables.css +0 -96
  207. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +0 -387
  208. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +0 -468
  209. package/src/RedactoPrivacyCenter/table/index.ts +0 -2
  210. package/src/RedactoPrivacyCenter/types/images.d.ts +0 -29
  211. package/src/RedactoPrivacyCenter/ui/Footer.tsx +0 -69
  212. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +0 -54
  213. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +0 -93
  214. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +0 -47
  215. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +0 -83
  216. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +0 -93
  217. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +0 -48
  218. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +0 -77
  219. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +0 -55
  220. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +0 -107
  221. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +0 -35
  222. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +0 -132
  223. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +0 -31
  224. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +0 -106
  225. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +0 -206
  226. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +0 -433
  227. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +0 -149
  228. package/src/RedactoPrivacyCenter/ui/PCText.tsx +0 -41
  229. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +0 -43
  230. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +0 -37
  231. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +0 -121
  232. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +0 -25
  233. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +0 -53
  234. package/src/RedactoPrivacyCenter/ui/index.ts +0 -18
  235. package/src/RedactoPrivacyCenter/utils/cn.ts +0 -28
  236. package/src/RedactoPrivacyCenter/utils/date.ts +0 -48
  237. package/src/RedactoPrivacyCenter/utils/status.ts +0 -111
  238. package/src/RedactoPrivacyCenter/utils/toast.ts +0 -130
  239. package/src/index.ts +0 -19
  240. package/src/privacy-center.ts +0 -113
  241. package/src/shared/api-errors.ts +0 -180
  242. package/src/shared/notice-consent-status.ts +0 -190
  243. package/src/shared/sandbox.ts +0 -6
  244. package/src/types/css.d.ts +0 -4
  245. package/src/types/images.d.ts +0 -19
  246. package/src/types/react-jsx.d.ts +0 -26
  247. package/tests/DataAvailabilityContext.test.tsx +0 -123
  248. package/tests/Form.test.tsx +0 -1424
  249. package/tests/ProductSelectionSection.test.tsx +0 -328
  250. package/tests/RequestSubmit.test.tsx +0 -135
  251. package/tests/RevokeWarningMessage.test.tsx +0 -151
  252. package/tests/consentClient.test.ts +0 -217
  253. package/tests/manageConsentServer.test.ts +0 -38
  254. package/tests/mocks.ts +0 -257
  255. package/tests/resolveContactFromJwt.test.ts +0 -44
  256. package/tests/setup.ts +0 -16
  257. package/tsconfig.json +0 -16
  258. package/tsup.config.ts +0 -31
  259. package/vitest.config.ts +0 -16
@@ -1,2160 +0,0 @@
1
- import { useState, useEffect, useMemo, useRef, useCallback } from "react";
2
- import type {
3
- CSSProperties,
4
- ChangeEvent,
5
- KeyboardEvent,
6
- ClipboardEvent,
7
- } from "react";
8
- import { styles } from "./styles";
9
- import { injectAssistedStyles } from "./injectStyles";
10
- import { useMediaQuery } from "./useMediaQuery";
11
- import { ASSISTED_CONFIG, DPO_AUDIO_ELEMENT_MAP, EMAIL_REGEX } from "./constants";
12
- import type { RedactoNoticeAssistedProps } from "./types";
13
- import {
14
- fetchAssistedNotice,
15
- fetchAssistedTTSAudioUrls,
16
- createOtp,
17
- verifyOtp,
18
- submitAssistedConsent,
19
- } from "./api";
20
- import type {
21
- ActiveConfig,
22
- Purpose,
23
- TTSAudioUrlsResponse,
24
- } from "./api/types";
25
- import { ApiError } from "../shared/api-errors";
26
- import {
27
- fetchNoticeConsentStatusForScope,
28
- isNoticeFullyConsented,
29
- } from "../shared/notice-consent-status";
30
- import {
31
- toNativeLabel,
32
- toBcp47Code,
33
- } from "../RedactoNoticeConsent/language-codes";
34
- import { makeStaticT } from "./i18n";
35
- import type { StaticTranslate } from "./i18n";
36
- import logo from "./assets/redacto-logo.png";
37
-
38
- export type { RedactoNoticeAssistedProps } from "./types";
39
-
40
- // Single progressive screen: the notice is always mounted; `verify` reveals the
41
- // inline mobile/OTP panel below it. `otpSent` gates the OTP boxes within `verify`.
42
- type Step = "notice" | "verify";
43
-
44
- /** Resolve a (possibly translated) string for the current language. */
45
- type Translate = (key: string, fallback: string, itemId?: string) => string;
46
-
47
- type ResponsiveStyles = {
48
- modal: CSSProperties;
49
- title: CSSProperties;
50
- subTitle: CSSProperties;
51
- optionTitle: CSSProperties;
52
- optionDescription: CSSProperties;
53
- dataElementText: CSSProperties;
54
- privacyText: CSSProperties;
55
- topSection: CSSProperties;
56
- topRight: CSSProperties;
57
- logo: CSSProperties;
58
- languageItem: CSSProperties;
59
- middleSection: CSSProperties;
60
- bottomSection: CSSProperties;
61
- button: CSSProperties;
62
- };
63
-
64
- /**
65
- * PurposeItem — accordion row for a single consent purpose (API shape).
66
- *
67
- * Chevron that rotates on expand, translated purpose name + description, a large
68
- * checkbox, and an expandable list of enabled data elements each with its own small
69
- * checkbox + required asterisk.
70
- */
71
- const PurposeItem = ({
72
- purpose,
73
- selectedPurposes,
74
- collapsedPurposes,
75
- selectedDataElements,
76
- onPurposeToggle,
77
- onPurposeCollapse,
78
- onDataElementToggle,
79
- responsive,
80
- t,
81
- ts,
82
- locked = false,
83
- }: {
84
- purpose: Purpose;
85
- selectedPurposes: Record<string, boolean>;
86
- collapsedPurposes: Record<string, boolean>;
87
- selectedDataElements: Record<string, boolean>;
88
- onPurposeToggle: (uuid: string) => void;
89
- onPurposeCollapse: (uuid: string) => void;
90
- onDataElementToggle: (purposeUuid: string, elementUuid: string) => void;
91
- responsive: ResponsiveStyles;
92
- t: Translate;
93
- ts: StaticTranslate;
94
- /** When true (verification started) the selection checkboxes are read-only. */
95
- locked?: boolean;
96
- }) => {
97
- const isExpanded = !collapsedPurposes[purpose.uuid];
98
- const name = t("purposes.name", purpose.name, purpose.uuid);
99
- const description = t(
100
- "purposes.description",
101
- purpose.description,
102
- purpose.uuid,
103
- );
104
- const dataElements = purpose.data_elements.filter(
105
- (el) => el.enabled !== false,
106
- );
107
- return (
108
- <div>
109
- <div style={styles.optionItem}>
110
- <div
111
- style={styles.optionLeft}
112
- onClick={() => onPurposeCollapse(purpose.uuid)}
113
- role="button"
114
- tabIndex={0}
115
- aria-expanded={isExpanded}
116
- aria-controls={`redacto-assisted-purpose-${purpose.uuid}`}
117
- aria-label={
118
- isExpanded
119
- ? ts("collapseDetails", { name })
120
- : ts("expandDetails", { name })
121
- }
122
- onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
123
- if (e.key === "Enter" || e.key === " ") {
124
- e.preventDefault();
125
- onPurposeCollapse(purpose.uuid);
126
- }
127
- }}
128
- >
129
- <div
130
- style={{
131
- display: "flex",
132
- alignItems: "center",
133
- justifyContent: "center",
134
- marginLeft: "5px",
135
- }}
136
- >
137
- <svg
138
- width="7"
139
- height="12"
140
- viewBox="0 0 7 12"
141
- fill="none"
142
- xmlns="http://www.w3.org/2000/svg"
143
- stroke="#323B4B"
144
- strokeWidth="2"
145
- strokeLinecap="round"
146
- strokeLinejoin="round"
147
- style={{
148
- transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
149
- transition: "transform 0.3s ease",
150
- }}
151
- aria-hidden="true"
152
- >
153
- <path d="M1 1L6 6L1 11" />
154
- </svg>
155
- </div>
156
- <div style={styles.optionTextContainer}>
157
- <h2
158
- id={`purpose-name-${purpose.uuid}`}
159
- style={{ ...styles.optionTitle, ...responsive.optionTitle }}
160
- >
161
- {name}
162
- </h2>
163
- {description && (
164
- <h3
165
- id={`purpose-description-${purpose.uuid}`}
166
- style={{
167
- ...styles.optionDescription,
168
- ...responsive.optionDescription,
169
- }}
170
- >
171
- {description}
172
- </h3>
173
- )}
174
- </div>
175
- </div>
176
- <input
177
- className="redacto-assisted-checkbox-large"
178
- type="checkbox"
179
- checked={selectedPurposes[purpose.uuid] || false}
180
- disabled={locked}
181
- onChange={() => onPurposeToggle(purpose.uuid)}
182
- aria-label={ts("selectAllFor", { name })}
183
- />
184
- </div>
185
- {isExpanded && dataElements.length > 0 && (
186
- <div
187
- id={`redacto-assisted-purpose-${purpose.uuid}`}
188
- style={styles.dataElementsContainer}
189
- >
190
- {dataElements.map((dataElement) => {
191
- const combinedId = `${purpose.uuid}-${dataElement.uuid}`;
192
- const elementName = t(
193
- "data_elements.name",
194
- dataElement.name,
195
- dataElement.uuid,
196
- );
197
- return (
198
- <div key={combinedId} style={styles.dataElementItem}>
199
- <span
200
- id={`data-element-${combinedId}`}
201
- style={{
202
- ...styles.dataElementText,
203
- ...responsive.dataElementText,
204
- }}
205
- >
206
- {elementName}
207
- {dataElement.required && (
208
- <span
209
- style={{ color: "red", marginLeft: "4px" }}
210
- aria-label={ts("required")}
211
- >
212
- *
213
- </span>
214
- )}
215
- </span>
216
- <input
217
- className="redacto-assisted-checkbox-small"
218
- type="checkbox"
219
- checked={selectedDataElements[combinedId] || false}
220
- disabled={locked}
221
- onChange={() =>
222
- onDataElementToggle(purpose.uuid, dataElement.uuid)
223
- }
224
- aria-label={
225
- dataElement.required
226
- ? ts("selectElementRequired", { name: elementName })
227
- : ts("selectElement", { name: elementName })
228
- }
229
- />
230
- </div>
231
- );
232
- })}
233
- </div>
234
- )}
235
- </div>
236
- );
237
- };
238
-
239
- /**
240
- * RedactoNoticeAssisted — agent-assisted consent collection.
241
- *
242
- * Fetches a consent notice from the PUBLIC notice endpoint (org/workspace/notice
243
- * UUIDs + baseUrl come from props — no auth) and renders it exactly like
244
- * RedactoNoticeConsent: logo + title, language pill, privacy text, "Manage What You
245
- * Share" accordion purposes, DPO footer, themed Confirm/Decline.
246
- *
247
- * Single progressive screen: the notice stays mounted throughout. Clicking the
248
- * primary CTA reveals an inline mobile/OTP panel below the (now locked) notice and
249
- * advances the CTA — Confirm → Send OTP → Confirm OTP. On successful verify +
250
- * submit, `onComplete` fires immediately with no confirmation screen (mirroring
251
- * RedactoNoticeConsent's `onAccept`). Steps: `notice` → `verify` (with `otpSent`).
252
- */
253
- export const RedactoNoticeAssisted = ({
254
- organisationUuid,
255
- workspaceUuid,
256
- noticeUuid,
257
- baseUrl,
258
- ledgerBaseUrl,
259
- onDecline,
260
- onComplete,
261
- }: RedactoNoticeAssistedProps) => {
262
- const isMobile = useMediaQuery("(max-width: 768px)");
263
-
264
- // Notice content fetched from the public API.
265
- const [content, setContent] = useState<ActiveConfig | null>(null);
266
- const [isLoading, setIsLoading] = useState(true);
267
- const [fetchError, setFetchError] = useState<Error | null>(null);
268
-
269
- const [step, setStep] = useState<Step>("notice");
270
-
271
- // Purpose / data-element / accordion state (keyed by API uuids; filled after fetch).
272
- const [selectedPurposes, setSelectedPurposes] = useState<
273
- Record<string, boolean>
274
- >({});
275
- const [selectedDataElements, setSelectedDataElements] = useState<
276
- Record<string, boolean>
277
- >({});
278
- const [collapsedPurposes, setCollapsedPurposes] = useState<
279
- Record<string, boolean>
280
- >({});
281
-
282
- // Header / language controls.
283
- const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
284
- const [selectedLanguage, setSelectedLanguage] = useState("English");
285
- const languageContainerRef = useRef<HTMLDivElement>(null);
286
-
287
- // Audio narration (talkback): per-element TTS clips played in render order, with
288
- // the playing element highlighted + scrolled into view + auto-expanded. Mirrors
289
- // RedactoNoticeConsent; narration is notice-only (stops on entering verify).
290
- const [ttsAudioUrls, setTtsAudioUrls] = useState<
291
- TTSAudioUrlsResponse["detail"] | null
292
- >(null);
293
- const [isTTSAvailable, setIsTTSAvailable] = useState(false);
294
- const [isPlaying, setIsPlaying] = useState(false);
295
- const [isPaused, setIsPaused] = useState(false);
296
- const [currentAudioIndex, setCurrentAudioIndex] = useState(0);
297
- const [highlightedElementId, setHighlightedElementId] = useState<
298
- string | null
299
- >(null);
300
- const currentAudioRef = useRef<HTMLAudioElement | null>(null);
301
-
302
- // How the customer verifies — by mobile (SMS) or by email. Mobile is the
303
- // default so the existing SMS flow is unchanged unless they pick Email.
304
- const [verifyMethod, setVerifyMethod] = useState<"mobile" | "email">("mobile");
305
- // Mobile number the OTP is sent to (entered in the inline panel, starts empty).
306
- const [mobile, setMobile] = useState("");
307
- const mobileInputRef = useRef<HTMLInputElement | null>(null);
308
- // Email address the OTP is sent to (used when verifyMethod === "email").
309
- const [email, setEmail] = useState("");
310
- const emailInputRef = useRef<HTMLInputElement | null>(null);
311
- // Whether "Send OTP" has been pressed — gates the OTP-entry UI in the panel.
312
- const [otpSent, setOtpSent] = useState(false);
313
-
314
- // Refs for the progressive-reveal auto-scroll: the verify panel (revealed on
315
- // Confirm) and the OTP boxes section (revealed on Send OTP).
316
- const verifyBlockRef = useRef<HTMLDivElement | null>(null);
317
- const otpSectionRef = useRef<HTMLDivElement | null>(null);
318
-
319
- // OTP entry state.
320
- const [otpDigits, setOtpDigits] = useState<string[]>(() =>
321
- Array.from({ length: ASSISTED_CONFIG.otpLength }, () => ""),
322
- );
323
- const otpInputRefs = useRef<Array<HTMLInputElement | null>>([]);
324
- const [resendCountdown, setResendCountdown] = useState(0);
325
-
326
- // OTP uuid from create-OTP (needed to verify-OTP).
327
- const [otpUuid, setOtpUuid] = useState("");
328
- // In-flight + error state for the create / verify+submit network calls.
329
- const [isSendingOtp, setIsSendingOtp] = useState(false);
330
- const [isVerifying, setIsVerifying] = useState(false);
331
- const [createError, setCreateError] = useState<string | null>(null);
332
- const [otpError, setOtpError] = useState<string | null>(null);
333
-
334
- // Inject checkbox / OTP / animation CSS once on mount.
335
- useEffect(() => {
336
- injectAssistedStyles();
337
- }, []);
338
-
339
- // Fetch the notice from the public endpoint.
340
- useEffect(() => {
341
- const controller = new AbortController();
342
- setIsLoading(true);
343
- setFetchError(null);
344
-
345
- fetchAssistedNotice({
346
- organisationUuid,
347
- workspaceUuid,
348
- noticeUuid,
349
- baseUrl,
350
- signal: controller.signal,
351
- })
352
- .then((data) => {
353
- const activeConfig = data.detail.active_config;
354
- setContent(activeConfig);
355
-
356
- // Default language → English label when the API reports "en".
357
- const dl = activeConfig.default_language;
358
- setSelectedLanguage(
359
- dl === "en" || dl === "EN" ? "English" : dl || "English",
360
- );
361
-
362
- // Seed selection / collapsed state from the purposes.
363
- const purposeState: Record<string, boolean> = {};
364
- const elementState: Record<string, boolean> = {};
365
- const collapsedState: Record<string, boolean> = {};
366
- activeConfig.purposes.forEach((p) => {
367
- purposeState[p.uuid] = false;
368
- collapsedState[p.uuid] = true;
369
- p.data_elements.forEach((el) => {
370
- elementState[`${p.uuid}-${el.uuid}`] = false;
371
- });
372
- });
373
- setSelectedPurposes(purposeState);
374
- setSelectedDataElements(elementState);
375
- setCollapsedPurposes(collapsedState);
376
- setIsLoading(false);
377
- })
378
- .catch((err: unknown) => {
379
- if (err instanceof Error && err.name === "AbortError") return;
380
- setFetchError(err instanceof Error ? err : new Error(String(err)));
381
- setIsLoading(false);
382
- });
383
-
384
- return () => controller.abort();
385
- }, [organisationUuid, workspaceUuid, noticeUuid, baseUrl]);
386
-
387
- // Resend countdown ticker (runs only while waiting on the OTP screen).
388
- useEffect(() => {
389
- if (resendCountdown <= 0) return;
390
- const intervalId = window.setInterval(() => {
391
- setResendCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
392
- }, 1000);
393
- return () => window.clearInterval(intervalId);
394
- }, [resendCountdown]);
395
-
396
- // Progressive reveal: when the verify panel mounts, scroll it into view so the
397
- // notice "scrolls up a little" and the new mobile field is visible.
398
- useEffect(() => {
399
- if (step !== "verify") return;
400
- const id = window.setTimeout(
401
- () => scrollIntoViewSoft(verifyBlockRef.current),
402
- 60,
403
- );
404
- return () => window.clearTimeout(id);
405
- }, [step]);
406
-
407
- // When the OTP boxes appear (after Send OTP), nudge them into view too.
408
- useEffect(() => {
409
- if (!otpSent) return;
410
- const id = window.setTimeout(
411
- () => scrollIntoViewSoft(otpSectionRef.current),
412
- 60,
413
- );
414
- return () => window.clearTimeout(id);
415
- }, [otpSent]);
416
-
417
- // Close the language dropdown on outside click.
418
- useEffect(() => {
419
- if (!isLanguageDropdownOpen) return;
420
- const handleClickOutside = (e: MouseEvent) => {
421
- if (
422
- languageContainerRef.current &&
423
- !languageContainerRef.current.contains(e.target as Node)
424
- ) {
425
- setIsLanguageDropdownOpen(false);
426
- }
427
- };
428
- document.addEventListener("mousedown", handleClickOutside);
429
- return () => document.removeEventListener("mousedown", handleClickOutside);
430
- }, [isLanguageDropdownOpen]);
431
-
432
- const otpValue = otpDigits.join("");
433
- const isOtpComplete = otpValue.length === ASSISTED_CONFIG.otpLength;
434
- const isMobileValid = mobile.replace(/\D/g, "").length >= 10;
435
- const isEmailValid = EMAIL_REGEX.test(email.trim());
436
- // Whichever contact the chosen method needs must be valid to send the OTP.
437
- const isContactValid = verifyMethod === "email" ? isEmailValid : isMobileValid;
438
-
439
- // Gate the notice-step Confirm CTA: every required data element across all
440
- // purposes must be selected before consent can proceed (mirrors
441
- // RedactoNoticeConsent's areAllRequiredElementsChecked).
442
- const areAllRequiredElementsChecked = useMemo(() => {
443
- if (!content) return false;
444
- return content.purposes.every((purpose) => {
445
- const requiredElements = purpose.data_elements.filter(
446
- (el) => el.required,
447
- );
448
- return requiredElements.every(
449
- (el) => selectedDataElements[`${purpose.uuid}-${el.uuid}`],
450
- );
451
- });
452
- }, [content, selectedDataElements]);
453
-
454
- // ── Translations ──────────────────────────────────────────────────────────
455
- const normalizedDefaultLang = useMemo(() => {
456
- const dl = content?.default_language;
457
- if (!dl) return "English";
458
- return dl === "en" || dl === "EN" ? "English" : dl;
459
- }, [content]);
460
-
461
- // The top-level active_config fields hold the BASE (English) copy; every other
462
- // language — including a non-English `default_language` like Hindi — is resolved
463
- // from `supported_languages_and_translations`. So only English maps to the base
464
- // content (mirrors RedactoNoticeConsent's isDefaultLanguage).
465
- const isDefaultLanguage = useCallback(
466
- (lang: string) => lang === "English" || lang === "en" || lang === "EN",
467
- [],
468
- );
469
-
470
- // Static (non-API) UI strings follow the same in-notice language picker.
471
- const ts = useMemo<StaticTranslate>(
472
- () => makeStaticT(toBcp47Code(selectedLanguage)),
473
- [selectedLanguage],
474
- );
475
-
476
- const getTranslatedText = useCallback<Translate>(
477
- (key, fallback, itemId) => {
478
- if (!content) return fallback;
479
- if (isDefaultLanguage(selectedLanguage)) {
480
- // Base purpose/element text is passed as the fallback for the default lang.
481
- if (itemId) return fallback;
482
- const v = (content as Record<string, unknown>)[key];
483
- return typeof v === "string" && v ? v : fallback;
484
- }
485
- const map =
486
- content.supported_languages_and_translations?.[selectedLanguage];
487
- if (!map) return fallback;
488
- if (itemId) {
489
- if (key === "purposes.name" || key === "purposes.description") {
490
- const p = map.purposes?.[itemId];
491
- if (!p) return fallback;
492
- if (typeof p === "string") {
493
- return key === "purposes.name" ? p : fallback;
494
- }
495
- return key === "purposes.name"
496
- ? p.name || fallback
497
- : p.description || fallback;
498
- }
499
- if (key.startsWith("data_elements.")) {
500
- return map.data_elements?.[itemId] || fallback;
501
- }
502
- return fallback;
503
- }
504
- const v = (map as Record<string, unknown>)[key];
505
- return typeof v === "string" && v ? v : fallback;
506
- },
507
- [content, selectedLanguage, isDefaultLanguage],
508
- );
509
-
510
- const getTranslatedDpoText = useCallback(
511
- (key: string, fallback: string): string => {
512
- if (!content?.dpo_info) return fallback;
513
- const base =
514
- (content.dpo_info as Record<string, string>)[key] || fallback;
515
- if (isDefaultLanguage(selectedLanguage)) return base;
516
- const map =
517
- content.supported_languages_and_translations?.[selectedLanguage];
518
- const translated = (
519
- map?.dpo_info as Record<string, string> | undefined
520
- )?.[key];
521
- return translated || base;
522
- },
523
- [content, selectedLanguage, isDefaultLanguage],
524
- );
525
-
526
- const availableLanguages = useMemo(() => {
527
- if (!content) return [selectedLanguage];
528
- const pinned = ["English"];
529
- if (normalizedDefaultLang && normalizedDefaultLang !== "English") {
530
- pinned.push(normalizedDefaultLang);
531
- }
532
- const rest = content.supported_languages_and_translations
533
- ? Object.keys(content.supported_languages_and_translations)
534
- .filter((lang) => !pinned.includes(lang))
535
- .sort((a, b) => a.localeCompare(b, "en"))
536
- : [];
537
- return [...pinned, ...rest];
538
- }, [content, normalizedDefaultLang, selectedLanguage]);
539
-
540
- // ── Theming (from the notice) ───────────────────────────────────────────────
541
- const primaryColor = content?.primary_color || "#4f87ff";
542
- const secondaryColor = content?.secondary_color || "#4f87ff";
543
- // Soft ring + light fill derived from the brand colour. Exposed to the injected
544
- // CSS (input/box focus rings) as CSS variables on the modal root.
545
- const accentSoft =
546
- hexToRgba(primaryColor, 0.18) || "rgba(79, 135, 255, 0.18)";
547
- const accentTint = hexToRgba(primaryColor, 0.08) || "#F5F8FF";
548
- const accentVars = {
549
- "--ra-accent": primaryColor,
550
- "--ra-accent-soft": accentSoft,
551
- } as CSSProperties;
552
- const fontFamily = content?.font_preference || undefined;
553
- const logoUrl = content?.logo_url || logo;
554
- const linkStyle: CSSProperties = { ...styles.link, color: secondaryColor };
555
-
556
- // Responsive overrides, mirroring RedactoNoticeConsent's useMemo pattern.
557
- const responsive = useMemo<ResponsiveStyles>(
558
- () => ({
559
- modal: { width: isMobile ? "90%" : "700px" },
560
- title: { fontSize: isMobile ? "16px" : "18px" },
561
- subTitle: { fontSize: isMobile ? "14px" : "16px" },
562
- optionTitle: { fontSize: isMobile ? "14px" : "16px" },
563
- optionDescription: { fontSize: isMobile ? "12px" : "14px" },
564
- dataElementText: { fontSize: isMobile ? "12px" : "14px" },
565
- privacyText: { fontSize: isMobile ? "14px" : "16px" },
566
- topSection: {
567
- flexDirection: isMobile ? "column" : "row",
568
- alignItems: isMobile ? "stretch" : "flex-start",
569
- gap: isMobile ? "12px" : "10px",
570
- },
571
- topRight: {
572
- fontSize: isMobile ? "11px" : "12px",
573
- padding: isMobile ? "3px 6px" : "3px 9px",
574
- },
575
- logo: {
576
- maxWidth: isMobile ? "96px" : "140px",
577
- maxHeight: isMobile ? "28px" : "32px",
578
- },
579
- languageItem: {
580
- fontSize: isMobile ? "11px" : "12px",
581
- padding: isMobile ? "6px 10px" : "8px 12px",
582
- },
583
- middleSection: { paddingRight: isMobile ? "10px" : "15px" },
584
- bottomSection: {
585
- flexDirection: isMobile ? "column" : "row",
586
- gap: isMobile ? "12px" : "29px",
587
- },
588
- button: {
589
- fontSize: isMobile ? "14px" : "16px",
590
- padding: isMobile ? "10px 20px" : "9px 45px",
591
- },
592
- }),
593
- [isMobile],
594
- );
595
-
596
- const togglePurpose = useCallback(
597
- (uuid: string) => {
598
- setSelectedPurposes((prev) => {
599
- const next = !prev[uuid];
600
- // Mirror the toggle onto this purpose's data elements.
601
- setSelectedDataElements((elements) => {
602
- const updated = { ...elements };
603
- const purpose = content?.purposes.find((p) => p.uuid === uuid);
604
- purpose?.data_elements.forEach((el) => {
605
- updated[`${uuid}-${el.uuid}`] = next;
606
- });
607
- return updated;
608
- });
609
- return { ...prev, [uuid]: next };
610
- });
611
- },
612
- [content],
613
- );
614
-
615
- const togglePurposeCollapse = useCallback((uuid: string) => {
616
- setCollapsedPurposes((prev) => ({ ...prev, [uuid]: !prev[uuid] }));
617
- }, []);
618
-
619
- const handleDataElementToggle = useCallback(
620
- (purposeUuid: string, elementUuid: string) => {
621
- if (!content) return;
622
- const combinedId = `${purposeUuid}-${elementUuid}`;
623
- setSelectedDataElements((prev) => {
624
- const next = { ...prev, [combinedId]: !prev[combinedId] };
625
- // Recompute the parent purpose from the new element state: checked once
626
- // all required elements are selected (or any element, when none are
627
- // required). Mirrors RedactoNoticeConsent's two-way checkbox binding.
628
- const purpose = content.purposes.find((p) => p.uuid === purposeUuid);
629
- if (purpose) {
630
- const requiredElements = purpose.data_elements.filter(
631
- (el) => el.required,
632
- );
633
- const shouldCheckPurpose =
634
- requiredElements.length > 0
635
- ? requiredElements.every(
636
- (el) => next[`${purposeUuid}-${el.uuid}`],
637
- )
638
- : purpose.data_elements.some(
639
- (el) => next[`${purposeUuid}-${el.uuid}`],
640
- );
641
- setSelectedPurposes((prevPurposes) => ({
642
- ...prevPurposes,
643
- [purposeUuid]: shouldCheckPurpose,
644
- }));
645
- }
646
- return next;
647
- });
648
- },
649
- [content],
650
- );
651
-
652
- const toggleLanguageDropdown = useCallback(() => {
653
- setIsLanguageDropdownOpen((prev) => !prev);
654
- }, []);
655
-
656
- const handleLanguageSelect = useCallback((lang: string) => {
657
- setSelectedLanguage(lang);
658
- setIsLanguageDropdownOpen(false);
659
- }, []);
660
-
661
- // ── Audio narration (talkback) ───────────────────────────────────────────────
662
- // Build the ordered list of clip URLs to play, matching the on-screen text order.
663
- // (Mirrors RedactoNoticeConsent's audioSequence, but iterates content.purposes
664
- // directly — the assisted flow has no already-consented categorisation.)
665
- const audioSequence = useMemo(() => {
666
- if (!ttsAudioUrls || !content) return [];
667
-
668
- const sequence: string[] = [];
669
-
670
- if (ttsAudioUrls.notice_banner_heading_audio_url) {
671
- sequence.push(ttsAudioUrls.notice_banner_heading_audio_url);
672
- }
673
- if (ttsAudioUrls.notice_text_audio_url) {
674
- sequence.push(ttsAudioUrls.notice_text_audio_url);
675
- }
676
- if (ttsAudioUrls.privacy_policy_prefix_text_audio_url) {
677
- sequence.push(ttsAudioUrls.privacy_policy_prefix_text_audio_url);
678
- }
679
- if (ttsAudioUrls.privacy_policy_anchor_text_audio_url) {
680
- sequence.push(ttsAudioUrls.privacy_policy_anchor_text_audio_url);
681
- }
682
- if (ttsAudioUrls.purpose_section_heading_audio_url) {
683
- sequence.push(ttsAudioUrls.purpose_section_heading_audio_url);
684
- }
685
-
686
- (content.purposes || []).forEach((purpose) => {
687
- if (ttsAudioUrls.purposes_audio[purpose.uuid]?.name_audio_url) {
688
- sequence.push(ttsAudioUrls.purposes_audio[purpose.uuid].name_audio_url);
689
- }
690
- if (ttsAudioUrls.purposes_audio[purpose.uuid]?.description_audio_url) {
691
- sequence.push(
692
- ttsAudioUrls.purposes_audio[purpose.uuid].description_audio_url,
693
- );
694
- }
695
- if (purpose.data_elements && purpose.data_elements.length > 0) {
696
- purpose.data_elements.forEach((dataElement) => {
697
- if (
698
- ttsAudioUrls.data_elements_audio[dataElement.uuid]?.name_audio_url
699
- ) {
700
- sequence.push(
701
- ttsAudioUrls.data_elements_audio[dataElement.uuid].name_audio_url,
702
- );
703
- }
704
- });
705
- }
706
- });
707
-
708
- if (content.additional_text && ttsAudioUrls.additional_text_audio_url) {
709
- sequence.push(ttsAudioUrls.additional_text_audio_url);
710
- }
711
- if (
712
- content.privacy_center_url &&
713
- ttsAudioUrls.privacy_center_anchor_text_audio_url
714
- ) {
715
- sequence.push(ttsAudioUrls.privacy_center_anchor_text_audio_url);
716
- }
717
-
718
- if (content.dpo_info && ttsAudioUrls.dpo_info_audio) {
719
- const dpoAudio = ttsAudioUrls.dpo_info_audio;
720
- DPO_AUDIO_ELEMENT_MAP.forEach(({ audioKey }) => {
721
- if (dpoAudio[audioKey]) {
722
- sequence.push(dpoAudio[audioKey] as string);
723
- }
724
- });
725
- }
726
-
727
- if (ttsAudioUrls.confirm_button_text_audio_url) {
728
- sequence.push(ttsAudioUrls.confirm_button_text_audio_url);
729
- }
730
- if (ttsAudioUrls.decline_button_text_audio_url) {
731
- sequence.push(ttsAudioUrls.decline_button_text_audio_url);
732
- }
733
-
734
- return sequence;
735
- }, [ttsAudioUrls, content]);
736
-
737
- // DOM element IDs in lock-step with audioSequence, so the playing clip can
738
- // highlight + scroll to the matching element.
739
- const elementIdSequence = useMemo(() => {
740
- if (!ttsAudioUrls || !content) return [];
741
-
742
- const elementIds: (string | null)[] = [];
743
-
744
- if (ttsAudioUrls.notice_banner_heading_audio_url) {
745
- elementIds.push("privacy-notice-title");
746
- }
747
- if (ttsAudioUrls.notice_text_audio_url) {
748
- elementIds.push("privacy-notice-text");
749
- }
750
- if (ttsAudioUrls.privacy_policy_prefix_text_audio_url) {
751
- elementIds.push("privacy-policy-text");
752
- }
753
- if (ttsAudioUrls.privacy_policy_anchor_text_audio_url) {
754
- elementIds.push("privacy-policy-link");
755
- }
756
- if (ttsAudioUrls.purpose_section_heading_audio_url) {
757
- elementIds.push("purpose-section-heading");
758
- }
759
-
760
- (content.purposes || []).forEach((purpose) => {
761
- if (ttsAudioUrls.purposes_audio[purpose.uuid]?.name_audio_url) {
762
- elementIds.push(`purpose-name-${purpose.uuid}`);
763
- }
764
- if (ttsAudioUrls.purposes_audio[purpose.uuid]?.description_audio_url) {
765
- elementIds.push(`purpose-description-${purpose.uuid}`);
766
- }
767
- if (purpose.data_elements && purpose.data_elements.length > 0) {
768
- purpose.data_elements.forEach((dataElement) => {
769
- if (
770
- ttsAudioUrls.data_elements_audio[dataElement.uuid]?.name_audio_url
771
- ) {
772
- elementIds.push(`data-element-${purpose.uuid}-${dataElement.uuid}`);
773
- }
774
- });
775
- }
776
- });
777
-
778
- if (content.additional_text && ttsAudioUrls.additional_text_audio_url) {
779
- elementIds.push("additional-text");
780
- }
781
- if (
782
- content.privacy_center_url &&
783
- ttsAudioUrls.privacy_center_anchor_text_audio_url
784
- ) {
785
- elementIds.push("privacy-center-link");
786
- }
787
-
788
- if (content.dpo_info && ttsAudioUrls.dpo_info_audio) {
789
- const dpoAudio = ttsAudioUrls.dpo_info_audio;
790
- DPO_AUDIO_ELEMENT_MAP.forEach(({ audioKey, elementId }) => {
791
- if (dpoAudio[audioKey]) {
792
- elementIds.push(elementId);
793
- }
794
- });
795
- }
796
-
797
- if (ttsAudioUrls.confirm_button_text_audio_url) {
798
- elementIds.push("confirm-button-text");
799
- }
800
- if (ttsAudioUrls.decline_button_text_audio_url) {
801
- elementIds.push("decline-button-text");
802
- }
803
-
804
- return elementIds;
805
- }, [ttsAudioUrls, content]);
806
-
807
- const stopAudio = useCallback(() => {
808
- if (currentAudioRef.current) {
809
- currentAudioRef.current.pause();
810
- currentAudioRef.current.currentTime = 0;
811
- currentAudioRef.current = null;
812
- }
813
- setIsPlaying(false);
814
- setIsPaused(false);
815
- setCurrentAudioIndex(0);
816
- setHighlightedElementId(null);
817
- }, []);
818
-
819
- const playNextAudio = useCallback(
820
- (index: number) => {
821
- if (index >= audioSequence.length) {
822
- stopAudio();
823
- return;
824
- }
825
-
826
- if (currentAudioRef.current) {
827
- currentAudioRef.current.pause();
828
- currentAudioRef.current = null;
829
- }
830
-
831
- const audio = new Audio(audioSequence[index]);
832
- currentAudioRef.current = audio;
833
- setCurrentAudioIndex(index);
834
- setIsPlaying(true);
835
- setIsPaused(false);
836
-
837
- const elementId = elementIdSequence[index];
838
- if (elementId) {
839
- setHighlightedElementId(elementId);
840
- }
841
-
842
- audio.onended = () => {
843
- playNextAudio(index + 1);
844
- };
845
- audio.onerror = () => {
846
- // eslint-disable-next-line no-console
847
- console.warn(`Failed to load audio at index ${index}`);
848
- playNextAudio(index + 1);
849
- };
850
- audio.play().catch((error) => {
851
- // eslint-disable-next-line no-console
852
- console.warn(`Failed to play audio at index ${index}:`, error);
853
- setIsPlaying(false);
854
- });
855
- },
856
- [audioSequence, elementIdSequence, stopAudio],
857
- );
858
-
859
- const playAudio = useCallback(() => {
860
- if (audioSequence.length === 0) return;
861
-
862
- if (isPaused && currentAudioRef.current) {
863
- currentAudioRef.current
864
- .play()
865
- .then(() => {
866
- setIsPlaying(true);
867
- setIsPaused(false);
868
- })
869
- .catch((error) => {
870
- // eslint-disable-next-line no-console
871
- console.warn("Failed to resume audio:", error);
872
- });
873
- } else {
874
- const startIndex = isPlaying ? currentAudioIndex : 0;
875
- playNextAudio(startIndex);
876
- }
877
- }, [audioSequence, isPaused, isPlaying, currentAudioIndex, playNextAudio]);
878
-
879
- const pauseAudio = useCallback(() => {
880
- if (currentAudioRef.current && isPlaying) {
881
- currentAudioRef.current.pause();
882
- setIsPlaying(false);
883
- setIsPaused(true);
884
- }
885
- }, [isPlaying]);
886
-
887
- const toggleAudio = useCallback(() => {
888
- if (isPlaying) {
889
- pauseAudio();
890
- } else {
891
- playAudio();
892
- }
893
- }, [isPlaying, playAudio, pauseAudio]);
894
-
895
- // Fetch the TTS clips when the notice loads / language changes. Public endpoint:
896
- // org/workspace/notice from props, language is the English name in selectedLanguage.
897
- useEffect(() => {
898
- setIsTTSAvailable(false);
899
- setTtsAudioUrls(null);
900
- stopAudio();
901
-
902
- if (!content || !content.notice_uuid) return;
903
-
904
- const ttsAbortController = new AbortController();
905
- fetchAssistedTTSAudioUrls({
906
- organisationUuid,
907
- workspaceUuid,
908
- noticeUuid: content.notice_uuid,
909
- language: selectedLanguage,
910
- baseUrl,
911
- signal: ttsAbortController.signal,
912
- })
913
- .then((response) => {
914
- setTtsAudioUrls(response.detail);
915
- setIsTTSAvailable(true);
916
- })
917
- .catch((error: unknown) => {
918
- // Fail quiet — a missing narration must never break the notice.
919
- if (error instanceof Error && error.name !== "AbortError") {
920
- setIsTTSAvailable(false);
921
- setTtsAudioUrls(null);
922
- }
923
- });
924
-
925
- return () => ttsAbortController.abort();
926
- // stopAudio is stable (no deps); selectedLanguage is the audio path segment.
927
- }, [
928
- content,
929
- selectedLanguage,
930
- organisationUuid,
931
- workspaceUuid,
932
- baseUrl,
933
- stopAudio,
934
- ]);
935
-
936
- // Auto-expand a collapsed purpose when its clip starts (so the highlight is visible).
937
- useEffect(() => {
938
- if (!isPlaying || !content || audioSequence.length === 0) return;
939
-
940
- const currentIndex = currentAudioIndex;
941
- // Skip the header clips (banner, notice text, privacy-policy x2, section heading).
942
- const headerAudioCount = 5;
943
- if (currentIndex < headerAudioCount) return;
944
-
945
- let audioOffset = headerAudioCount;
946
- for (const purpose of content.purposes || []) {
947
- const dataElementsAudioCount = purpose.data_elements?.length || 0;
948
- const totalPurposeAudioCount = 2 + dataElementsAudioCount;
949
-
950
- if (
951
- currentIndex >= audioOffset &&
952
- currentIndex < audioOffset + totalPurposeAudioCount
953
- ) {
954
- if (collapsedPurposes[purpose.uuid]) {
955
- setCollapsedPurposes((prev) => ({ ...prev, [purpose.uuid]: false }));
956
- }
957
- break;
958
- }
959
- audioOffset += totalPurposeAudioCount;
960
- }
961
- }, [isPlaying, currentAudioIndex, audioSequence, content, collapsedPurposes]);
962
-
963
- // Highlight + scroll the element matching the currently-playing clip.
964
- useEffect(() => {
965
- const removeAllHighlights = () => {
966
- document.querySelectorAll(".redacto-tts-highlight").forEach((el) => {
967
- el.classList.remove("redacto-tts-highlight");
968
- });
969
- };
970
-
971
- if (!highlightedElementId || !isPlaying) {
972
- removeAllHighlights();
973
- return;
974
- }
975
-
976
- removeAllHighlights();
977
-
978
- const highlightTimeout = window.setTimeout(() => {
979
- const element = document.getElementById(highlightedElementId);
980
- if (!element) return;
981
- element.classList.add("redacto-tts-highlight");
982
- void element.offsetHeight;
983
-
984
- const rect = element.getBoundingClientRect();
985
- const isVisible =
986
- rect.top >= 0 &&
987
- rect.left >= 0 &&
988
- rect.bottom <=
989
- (window.innerHeight || document.documentElement.clientHeight) &&
990
- rect.right <=
991
- (window.innerWidth || document.documentElement.clientWidth);
992
- if (!isVisible) {
993
- element.scrollIntoView({
994
- behavior: "smooth",
995
- block: "center",
996
- inline: "nearest",
997
- });
998
- }
999
- }, 50);
1000
-
1001
- return () => {
1002
- window.clearTimeout(highlightTimeout);
1003
- removeAllHighlights();
1004
- };
1005
- }, [highlightedElementId, isPlaying]);
1006
-
1007
- // Confirm reveals the inline verify panel (mobile-entry sub-state) below the
1008
- // now-locked notice. The notice stays mounted; only the panel is appended.
1009
- const handleConfirmStep = useCallback(() => {
1010
- // Narration is notice-only — stop any playback before the verify panel opens.
1011
- stopAudio();
1012
- setStep("verify");
1013
- setOtpSent(false);
1014
- window.setTimeout(() => {
1015
- (verifyMethod === "email" ? emailInputRef : mobileInputRef).current?.focus();
1016
- }, 80);
1017
- }, [verifyMethod, stopAudio]);
1018
-
1019
- // "Edit selections" reopens the notice for editing: collapse the verify panel
1020
- // back to the notice step and discard any in-progress OTP session.
1021
- const handleEditSelections = useCallback(() => {
1022
- setStep("notice");
1023
- setOtpSent(false);
1024
- setOtpUuid("");
1025
- setMobile("");
1026
- setEmail("");
1027
- setOtpDigits(Array.from({ length: ASSISTED_CONFIG.otpLength }, () => ""));
1028
- setResendCountdown(0);
1029
- setCreateError(null);
1030
- setOtpError(null);
1031
- }, []);
1032
-
1033
- // The OTP recipient + channel for the create-OTP call. Email sends the address
1034
- // as-is over the "email" channel; mobile sends E.164 (dial code + digits) over
1035
- // "sms".
1036
- const otpRecipient =
1037
- verifyMethod === "email"
1038
- ? email.trim()
1039
- : `${ASSISTED_CONFIG.dialCode}${mobile}`;
1040
- const otpChannel =
1041
- verifyMethod === "email"
1042
- ? ASSISTED_CONFIG.emailChannel
1043
- : ASSISTED_CONFIG.channel;
1044
-
1045
- // Shared create-OTP call, used by both "Get OTP" and "Resend OTP". On success it
1046
- // reveals/keeps the OTP-entry UI on the same screen and (re)starts the countdown.
1047
- const sendOtp = useCallback(async () => {
1048
- setCreateError(null);
1049
- setIsSendingOtp(true);
1050
- try {
1051
- const detail = await createOtp({
1052
- organisationUuid,
1053
- workspaceUuid,
1054
- to: otpRecipient,
1055
- channel: otpChannel,
1056
- baseUrl,
1057
- });
1058
- setOtpUuid(detail.uuid);
1059
- setOtpDigits(Array.from({ length: ASSISTED_CONFIG.otpLength }, () => ""));
1060
- setOtpError(null);
1061
- setResendCountdown(ASSISTED_CONFIG.resendSeconds);
1062
- setOtpSent(true);
1063
- window.setTimeout(() => otpInputRefs.current[0]?.focus(), 0);
1064
- } catch {
1065
- // The send endpoint returns server copy in English; show a localized,
1066
- // generic failure so the assisted flow stays in the picked language.
1067
- setCreateError(ts("otpSendFailed"));
1068
- } finally {
1069
- setIsSendingOtp(false);
1070
- }
1071
- }, [organisationUuid, workspaceUuid, otpRecipient, otpChannel, baseUrl, ts]);
1072
-
1073
- const handleGetOtp = useCallback(() => {
1074
- if (!isContactValid || isSendingOtp) return;
1075
- void sendOtp();
1076
- }, [isContactValid, isSendingOtp, sendOtp]);
1077
-
1078
- // Switch verification method. Disabled once a code is out (otpSent / sending),
1079
- // so this only fires before "Send OTP"; clears any prior send error and focuses
1080
- // the newly chosen field.
1081
- const handleSelectMethod = useCallback(
1082
- (method: "mobile" | "email") => {
1083
- if (otpSent || isSendingOtp) return;
1084
- setVerifyMethod(method);
1085
- setCreateError(null);
1086
- window.setTimeout(() => {
1087
- (method === "email" ? emailInputRef : mobileInputRef).current?.focus();
1088
- }, 0);
1089
- },
1090
- [otpSent, isSendingOtp],
1091
- );
1092
-
1093
- const handleResend = useCallback(() => {
1094
- if (isSendingOtp) return;
1095
- void sendOtp();
1096
- }, [isSendingOtp, sendOtp]);
1097
-
1098
- // "Change Mobile/Email" re-enables the active contact field, discarding the OTP
1099
- // session and re-enabling the method toggle.
1100
- const handleChangeContact = useCallback(() => {
1101
- setOtpSent(false);
1102
- setOtpUuid("");
1103
- setOtpDigits(Array.from({ length: ASSISTED_CONFIG.otpLength }, () => ""));
1104
- setResendCountdown(0);
1105
- setCreateError(null);
1106
- setOtpError(null);
1107
- window.setTimeout(() => {
1108
- (verifyMethod === "email" ? emailInputRef : mobileInputRef).current?.focus();
1109
- }, 0);
1110
- }, [verifyMethod]);
1111
-
1112
- const handleOtpChange = useCallback((index: number, raw: string) => {
1113
- // Keep only the last typed digit (handles paste of a single char too).
1114
- const digit = raw.replace(/\D/g, "").slice(-1);
1115
- setOtpDigits((prev) => {
1116
- const next = [...prev];
1117
- next[index] = digit;
1118
- return next;
1119
- });
1120
- if (digit && index < ASSISTED_CONFIG.otpLength - 1) {
1121
- otpInputRefs.current[index + 1]?.focus();
1122
- }
1123
- }, []);
1124
-
1125
- const handleOtpKeyDown = useCallback(
1126
- (index: number, e: KeyboardEvent<HTMLInputElement>) => {
1127
- if (e.key === "Backspace" && !otpDigits[index] && index > 0) {
1128
- otpInputRefs.current[index - 1]?.focus();
1129
- }
1130
- },
1131
- [otpDigits],
1132
- );
1133
-
1134
- const handleOtpPaste = useCallback((e: ClipboardEvent<HTMLInputElement>) => {
1135
- const pasted = e.clipboardData
1136
- .getData("text")
1137
- .replace(/\D/g, "")
1138
- .slice(0, ASSISTED_CONFIG.otpLength);
1139
- if (!pasted) return;
1140
- e.preventDefault();
1141
- setOtpDigits(() =>
1142
- Array.from(
1143
- { length: ASSISTED_CONFIG.otpLength },
1144
- (_, i) => pasted[i] ?? "",
1145
- ),
1146
- );
1147
- const focusIndex = Math.min(pasted.length, ASSISTED_CONFIG.otpLength - 1);
1148
- otpInputRefs.current[focusIndex]?.focus();
1149
- }, []);
1150
-
1151
- // Reset back to the start so the flow can be replayed without remounting.
1152
- const resetFlow = useCallback(() => {
1153
- setStep("notice");
1154
- setSelectedPurposes((prev) =>
1155
- Object.fromEntries(Object.keys(prev).map((k) => [k, false])),
1156
- );
1157
- setVerifyMethod("mobile");
1158
- setMobile("");
1159
- setEmail("");
1160
- setOtpSent(false);
1161
- setOtpUuid("");
1162
- setOtpDigits(Array.from({ length: ASSISTED_CONFIG.otpLength }, () => ""));
1163
- setResendCountdown(0);
1164
- setCreateError(null);
1165
- setOtpError(null);
1166
- }, []);
1167
-
1168
- // Verification + submit succeeded: hand control to the host immediately — no
1169
- // confirmation screen (mirrors RedactoNoticeConsent calling onAccept right
1170
- // after a successful submit). Without onComplete, fall back to log + reset.
1171
- const handleSuccess = useCallback(() => {
1172
- if (onComplete) {
1173
- onComplete();
1174
- return;
1175
- }
1176
- // eslint-disable-next-line no-console
1177
- console.log("[RedactoNoticeAssisted] consent recorded");
1178
- resetFlow();
1179
- }, [onComplete, resetFlow]);
1180
-
1181
- // Verify OTP → on success, submit the consent with the returned access_token,
1182
- // then hand off via onComplete. Both steps run on one button press.
1183
- const handleConfirm = useCallback(async () => {
1184
- if (!isOtpComplete || isVerifying || !content) return;
1185
- setOtpError(null);
1186
- setIsVerifying(true);
1187
- try {
1188
- // Verify is its own step: the endpoint returns English copy with no
1189
- // error_code, so localize by HTTP status + the `should_request_new` flag
1190
- // and stop here on failure (keeps verify errors distinct from submit ones).
1191
- let verifyDetail;
1192
- try {
1193
- verifyDetail = await verifyOtp({
1194
- organisationUuid,
1195
- workspaceUuid,
1196
- uuid: otpUuid,
1197
- otp: otpValue,
1198
- purpose: ASSISTED_CONFIG.otpPurpose,
1199
- baseUrl,
1200
- });
1201
- } catch (err) {
1202
- if (err instanceof ApiError && err.status === 404) {
1203
- setOtpError(ts("otpNotFound"));
1204
- } else if (err instanceof ApiError && err.status === 400) {
1205
- setOtpError(
1206
- err.shouldRequestNew ? ts("otpExpired") : ts("otpIncorrect"),
1207
- );
1208
- } else {
1209
- setOtpError(ts("otpVerifyFailed"));
1210
- }
1211
- return;
1212
- }
1213
- const token = verifyDetail.access_token;
1214
-
1215
- // Post-cutover, consent records live in the Go ledger, so Python's 409
1216
- // CONSENT_ALREADY_PROVIDED can no longer be relied on. When a ledger is
1217
- // configured, ask it directly (org/workspace from props, OTP token as
1218
- // bearer) whether this principal has already fully consented and, if so,
1219
- // hand off without writing a duplicate consent event. Any failure returns
1220
- // null and falls through to the normal submit (graceful degradation).
1221
- if (ledgerBaseUrl) {
1222
- const ledgerStatus = await fetchNoticeConsentStatusForScope({
1223
- accessToken: token,
1224
- ledgerBaseUrl,
1225
- organisationUuid,
1226
- workspaceUuid,
1227
- noticeUuid: content.notice_uuid,
1228
- });
1229
- if (ledgerStatus && isNoticeFullyConsented(ledgerStatus)) {
1230
- handleSuccess();
1231
- return;
1232
- }
1233
- }
1234
-
1235
- // Map current selection onto the submit payload shape (required → selected).
1236
- const submitPurposes = content.purposes.map((purpose) => ({
1237
- ...purpose,
1238
- selected: selectedPurposes[purpose.uuid] || false,
1239
- data_elements: purpose.data_elements.map((element) => ({
1240
- ...element,
1241
- selected: element.required
1242
- ? true
1243
- : selectedDataElements[`${purpose.uuid}-${element.uuid}`] || false,
1244
- })),
1245
- }));
1246
-
1247
- await submitAssistedConsent({
1248
- accessToken: token,
1249
- organisationUuid,
1250
- workspaceUuid,
1251
- baseUrl,
1252
- ledgerBaseUrl,
1253
- noticeUuid: content.notice_uuid,
1254
- purposes: submitPurposes,
1255
- language: toBcp47Code(selectedLanguage),
1256
- });
1257
-
1258
- handleSuccess();
1259
- } catch (err) {
1260
- // Already-consented is not an error — mirror RedactoNoticeConsent and treat
1261
- // the 409 as success, handing off via onComplete with no error.
1262
- if (
1263
- err instanceof ApiError &&
1264
- (err.status === 409 || err.code === "CONSENT_ALREADY_PROVIDED")
1265
- ) {
1266
- handleSuccess();
1267
- return;
1268
- }
1269
- // Submit failed (verify already succeeded) — show a localized generic
1270
- // message rather than the server's English copy.
1271
- setOtpError(ts("otpVerifyFailed"));
1272
- } finally {
1273
- setIsVerifying(false);
1274
- }
1275
- }, [
1276
- isOtpComplete,
1277
- isVerifying,
1278
- content,
1279
- organisationUuid,
1280
- workspaceUuid,
1281
- otpUuid,
1282
- otpValue,
1283
- baseUrl,
1284
- ledgerBaseUrl,
1285
- selectedPurposes,
1286
- selectedDataElements,
1287
- selectedLanguage,
1288
- handleSuccess,
1289
- ts,
1290
- ]);
1291
-
1292
- // Lifecycle callback: when a host provides onDecline it owns the outcome (e.g.
1293
- // unmount the modal / navigate away). Without it, fall back to log + reset.
1294
- const handleDecline = useCallback(() => {
1295
- if (onDecline) {
1296
- onDecline();
1297
- return;
1298
- }
1299
- // eslint-disable-next-line no-console
1300
- console.log("[RedactoNoticeAssisted] consent declined");
1301
- resetFlow();
1302
- }, [onDecline, resetFlow]);
1303
-
1304
- const noticeTitle = getTranslatedText(
1305
- "notice_banner_heading",
1306
- content?.notice_banner_heading || "Privacy Notice",
1307
- );
1308
-
1309
- const renderHeader = () => {
1310
- const logoNode = (
1311
- <img
1312
- style={{ ...styles.logo, ...responsive.logo }}
1313
- src={logoUrl}
1314
- alt={ts("logoAlt")}
1315
- />
1316
- );
1317
- const titleNode = (
1318
- <h2
1319
- id="privacy-notice-title"
1320
- style={{ ...styles.title, ...responsive.title }}
1321
- >
1322
- {noticeTitle}
1323
- </h2>
1324
- );
1325
- const controlsBlock = (
1326
- <div style={styles.languageSelectorContainer} ref={languageContainerRef}>
1327
- <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
1328
- {/* Speaker button — narration is notice-only, so it shows only when TTS
1329
- is available and we're on the notice step. */}
1330
- {isTTSAvailable && step === "notice" && (
1331
- <button
1332
- type="button"
1333
- onClick={toggleAudio}
1334
- style={{
1335
- ...styles.topRight,
1336
- ...responsive.topRight,
1337
- padding: "3px 9px",
1338
- display: "flex",
1339
- alignItems: "center",
1340
- justifyContent: "center",
1341
- }}
1342
- aria-label={
1343
- isPlaying && !isPaused ? ts("audioPause") : ts("audioStart")
1344
- }
1345
- title={
1346
- isPlaying && !isPaused
1347
- ? ts("talkbackPause")
1348
- : ts("talkbackStart")
1349
- }
1350
- >
1351
- {isPlaying && !isPaused ? (
1352
- <svg
1353
- xmlns="http://www.w3.org/2000/svg"
1354
- width="16"
1355
- height="16"
1356
- viewBox="0 0 24 24"
1357
- fill="none"
1358
- stroke="currentColor"
1359
- strokeWidth="2"
1360
- strokeLinecap="round"
1361
- strokeLinejoin="round"
1362
- aria-hidden="true"
1363
- >
1364
- <rect x="6" y="4" width="4" height="16" />
1365
- <rect x="14" y="4" width="4" height="16" />
1366
- </svg>
1367
- ) : (
1368
- <svg
1369
- xmlns="http://www.w3.org/2000/svg"
1370
- width="16"
1371
- height="16"
1372
- viewBox="0 0 24 24"
1373
- fill="none"
1374
- stroke="currentColor"
1375
- strokeWidth="2"
1376
- strokeLinecap="round"
1377
- strokeLinejoin="round"
1378
- aria-hidden="true"
1379
- >
1380
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
1381
- <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
1382
- </svg>
1383
- )}
1384
- </button>
1385
- )}
1386
- <div>
1387
- <button
1388
- type="button"
1389
- style={{ ...styles.topRight, ...responsive.topRight }}
1390
- onClick={toggleLanguageDropdown}
1391
- aria-expanded={isLanguageDropdownOpen}
1392
- aria-haspopup="listbox"
1393
- aria-controls="redacto-assisted-language-listbox"
1394
- aria-label={ts("selectLanguageCurrent", {
1395
- lang: toNativeLabel(selectedLanguage),
1396
- })}
1397
- >
1398
- {toNativeLabel(selectedLanguage)}
1399
- <svg
1400
- xmlns="http://www.w3.org/2000/svg"
1401
- width="16"
1402
- height="16"
1403
- viewBox="0 0 16 16"
1404
- fill="none"
1405
- stroke="currentColor"
1406
- strokeWidth="2"
1407
- strokeLinecap="round"
1408
- strokeLinejoin="round"
1409
- style={{ flexShrink: 0, marginLeft: "4px" }}
1410
- aria-hidden="true"
1411
- >
1412
- <path d="M4 6l4 4 4-4" />
1413
- </svg>
1414
- </button>
1415
- </div>
1416
- </div>
1417
- {isLanguageDropdownOpen && (
1418
- <ul
1419
- id="redacto-assisted-language-listbox"
1420
- style={styles.languageDropdown}
1421
- role="listbox"
1422
- aria-label={ts("selectLanguage")}
1423
- >
1424
- {availableLanguages.map((lang) => (
1425
- <li
1426
- key={lang}
1427
- style={{
1428
- ...styles.languageItem,
1429
- ...responsive.languageItem,
1430
- ...(selectedLanguage === lang
1431
- ? styles.selectedLanguageItem
1432
- : null),
1433
- listStyle: "none",
1434
- }}
1435
- onClick={() => handleLanguageSelect(lang)}
1436
- role="option"
1437
- aria-selected={selectedLanguage === lang}
1438
- tabIndex={0}
1439
- >
1440
- {toNativeLabel(lang)}
1441
- </li>
1442
- ))}
1443
- </ul>
1444
- )}
1445
- </div>
1446
- );
1447
-
1448
- return (
1449
- <div style={{ ...styles.topSection, ...responsive.topSection }}>
1450
- {isMobile ? (
1451
- <>
1452
- <div
1453
- style={{
1454
- display: "flex",
1455
- alignItems: "center",
1456
- gap: "10px",
1457
- width: "100%",
1458
- }}
1459
- >
1460
- {logoNode}
1461
- <div
1462
- style={{
1463
- marginLeft: "auto",
1464
- display: "flex",
1465
- alignItems: "center",
1466
- flexShrink: 0,
1467
- }}
1468
- >
1469
- {controlsBlock}
1470
- </div>
1471
- </div>
1472
- {titleNode}
1473
- </>
1474
- ) : (
1475
- <>
1476
- <div style={styles.topLeft}>
1477
- {logoNode}
1478
- {titleNode}
1479
- </div>
1480
- {controlsBlock}
1481
- </>
1482
- )}
1483
- </div>
1484
- );
1485
- };
1486
-
1487
- const renderDpoFooter = () => {
1488
- if (!content) return null;
1489
- const dpoInfo = content.dpo_info;
1490
- if (!content.privacy_center_url && !dpoInfo) return null;
1491
- return (
1492
- <div
1493
- style={{
1494
- ...styles.privacyText,
1495
- ...responsive.privacyText,
1496
- fontSize: isMobile ? "13px" : "14px",
1497
- lineHeight: "1.4",
1498
- borderTop: "1px solid rgba(152, 162, 179, 0.3)",
1499
- paddingTop: "12px",
1500
- marginTop: "12px",
1501
- }}
1502
- >
1503
- <p style={{ margin: 0, opacity: 0.6 }}>
1504
- {content.privacy_center_url && (
1505
- <>
1506
- <span id="additional-text">
1507
- {getTranslatedText(
1508
- "additional_text",
1509
- content.additional_text ||
1510
- "Manage or withdraw consent anytime at",
1511
- )}
1512
- </span>{" "}
1513
- <a
1514
- id="privacy-center-link"
1515
- style={linkStyle}
1516
- href={content.privacy_center_url}
1517
- target="_blank"
1518
- rel="noopener noreferrer"
1519
- >
1520
- {getTranslatedText(
1521
- "privacy_center_anchor_text",
1522
- content.privacy_center_anchor_text || "Privacy Center",
1523
- )}
1524
- </a>
1525
- .
1526
- </>
1527
- )}
1528
- {dpoInfo && (
1529
- <>
1530
- {content.privacy_center_url && <br />}
1531
- <span id="dpo-grievance-text">
1532
- {getTranslatedDpoText("grievance_text", dpoInfo.grievance_text)}
1533
- </span>{" "}
1534
- {dpoInfo.grievance_url && (
1535
- <a
1536
- id="dpo-grievance-link"
1537
- style={linkStyle}
1538
- href={dpoInfo.grievance_url}
1539
- target="_blank"
1540
- rel="noopener noreferrer"
1541
- >
1542
- {getTranslatedDpoText(
1543
- "grievance_anchor_text",
1544
- dpoInfo.grievance_anchor_text || "click here",
1545
- )}
1546
- </a>
1547
- )}{" "}
1548
- {dpoInfo.grievance_email && (
1549
- <>
1550
- <span id="dpo-grievance-email-connector">
1551
- {getTranslatedDpoText(
1552
- "grievance_email_connector_text",
1553
- dpoInfo.grievance_email_connector_text || "or email to",
1554
- )}
1555
- </span>{" "}
1556
- <a
1557
- id="dpo-grievance-email"
1558
- style={linkStyle}
1559
- href={`mailto:${dpoInfo.grievance_email}`}
1560
- >
1561
- {dpoInfo.grievance_email}
1562
- </a>
1563
- </>
1564
- )}
1565
- <br />
1566
- <span id="dpo-dp-board-text">
1567
- {getTranslatedDpoText("dp_board_text", dpoInfo.dp_board_text)}
1568
- </span>{" "}
1569
- {dpoInfo.dp_board_url && (
1570
- <a
1571
- id="dpo-dp-board-link"
1572
- style={linkStyle}
1573
- href={dpoInfo.dp_board_url}
1574
- target="_blank"
1575
- rel="noopener noreferrer"
1576
- >
1577
- {getTranslatedDpoText(
1578
- "dp_board_anchor_text",
1579
- dpoInfo.dp_board_anchor_text || "click here",
1580
- )}
1581
- </a>
1582
- )}
1583
- <br />
1584
- <span id="dpo-dpo-text">
1585
- {getTranslatedDpoText("dpo_text", dpoInfo.dpo_text)}
1586
- </span>{" "}
1587
- {dpoInfo.dpo_email && (
1588
- <a
1589
- id="dpo-dpo-link"
1590
- style={linkStyle}
1591
- href={`mailto:${dpoInfo.dpo_email}`}
1592
- >
1593
- {getTranslatedDpoText(
1594
- "dpo_anchor_text",
1595
- dpoInfo.dpo_anchor_text || "Click here",
1596
- )}
1597
- </a>
1598
- )}
1599
- </>
1600
- )}
1601
- </p>
1602
- </div>
1603
- );
1604
- };
1605
-
1606
- const renderNoticeScreen = (locked: boolean) => {
1607
- if (!content) return null;
1608
- return (
1609
- <>
1610
- <p style={{ ...styles.privacyText, ...responsive.privacyText }}>
1611
- <span id="privacy-notice-text">
1612
- {getTranslatedText("notice_text", content.notice_text)}
1613
- </span>
1614
- <br />
1615
- <span>
1616
- <span id="privacy-policy-text">
1617
- {getTranslatedText(
1618
- "privacy_policy_prefix_text",
1619
- content.privacy_policy_prefix_text || "",
1620
- )}
1621
- </span>{" "}
1622
- <a
1623
- id="privacy-policy-link"
1624
- style={linkStyle}
1625
- href={content.privacy_policy_url || "#"}
1626
- target="_blank"
1627
- rel="noopener noreferrer"
1628
- aria-label={`${getTranslatedText(
1629
- "privacy_policy_anchor_text",
1630
- content.privacy_policy_anchor_text || "Privacy Policy",
1631
- )} (${ts("opensInNewTab")})`}
1632
- >
1633
- {getTranslatedText(
1634
- "privacy_policy_anchor_text",
1635
- content.privacy_policy_anchor_text || "Privacy Policy",
1636
- )}
1637
- </a>
1638
- </span>
1639
- </p>
1640
-
1641
- <h2
1642
- id="purpose-section-heading"
1643
- style={{ ...styles.subTitle, ...responsive.subTitle }}
1644
- >
1645
- {getTranslatedText(
1646
- "purpose_section_heading",
1647
- content.purpose_section_heading || "Manage What You Share",
1648
- )}
1649
- </h2>
1650
-
1651
- <div
1652
- style={{
1653
- ...styles.optionsContainer,
1654
- ...(locked ? styles.lockedOptions : null),
1655
- }}
1656
- aria-disabled={locked || undefined}
1657
- >
1658
- {content.purposes.map((purpose) => (
1659
- <PurposeItem
1660
- key={purpose.uuid}
1661
- purpose={purpose}
1662
- selectedPurposes={selectedPurposes}
1663
- collapsedPurposes={collapsedPurposes}
1664
- selectedDataElements={selectedDataElements}
1665
- onPurposeToggle={togglePurpose}
1666
- onPurposeCollapse={togglePurposeCollapse}
1667
- onDataElementToggle={handleDataElementToggle}
1668
- responsive={responsive}
1669
- t={getTranslatedText}
1670
- ts={ts}
1671
- locked={locked}
1672
- />
1673
- ))}
1674
- </div>
1675
-
1676
- {renderDpoFooter()}
1677
- </>
1678
- );
1679
- };
1680
-
1681
- // Inline verify panel — a separated card appended below the locked notice.
1682
- // Header carries a "Verify with OTP" title + an "Edit selections" link to
1683
- // reopen the notice. The mobile field stays visible (soft, locked once the
1684
- // code is sent), with a justified "code sent / Change number" row beneath it,
1685
- // then the code boxes. Accent colours come from the API brand colour.
1686
- const renderVerifyBlock = () => (
1687
- <div
1688
- ref={verifyBlockRef}
1689
- className="redacto-assisted-reveal"
1690
- style={styles.verifyPanel}
1691
- >
1692
- <div style={styles.verifyPanelHeader}>
1693
- <span style={styles.verifyPanelTitle}>
1694
- <LockIcon size={15} color={primaryColor} />
1695
- {ts("verifyWithOtp")}
1696
- </span>
1697
- <button
1698
- type="button"
1699
- style={{ ...styles.editLink, color: primaryColor }}
1700
- onClick={handleEditSelections}
1701
- disabled={isVerifying}
1702
- >
1703
- {ts("editSelections")}
1704
- </button>
1705
- </div>
1706
-
1707
- <div style={styles.phoneFieldWrap}>
1708
- {/* Label row: active method icon + label on the left, a quiet link on
1709
- the right to switch methods. The link is hidden once a code is sent
1710
- (the status row below takes over). */}
1711
- <div style={styles.fieldLabelRow}>
1712
- <label
1713
- htmlFor={
1714
- verifyMethod === "email"
1715
- ? "redacto-assisted-email"
1716
- : "redacto-assisted-mobile"
1717
- }
1718
- style={styles.fieldLabelWithIcon}
1719
- >
1720
- {verifyMethod === "email" ? (
1721
- <MailIcon size={14} color="#475467" />
1722
- ) : (
1723
- <PhoneIcon size={14} color="#475467" />
1724
- )}
1725
- {verifyMethod === "email" ? ts("emailAddress") : ts("mobileNumber")}
1726
- </label>
1727
- {!otpSent && (
1728
- <button
1729
- type="button"
1730
- className="redacto-assisted-switch-link"
1731
- style={{ ...styles.editLink, color: primaryColor }}
1732
- onClick={() =>
1733
- handleSelectMethod(verifyMethod === "email" ? "mobile" : "email")
1734
- }
1735
- disabled={isSendingOtp}
1736
- >
1737
- {verifyMethod === "email" ? (
1738
- <PhoneIcon size={14} color={primaryColor} />
1739
- ) : (
1740
- <MailIcon size={14} color={primaryColor} />
1741
- )}
1742
- {verifyMethod === "email"
1743
- ? ts("useMobileInstead")
1744
- : ts("useEmailInstead")}
1745
- </button>
1746
- )}
1747
- </div>
1748
-
1749
- {verifyMethod === "mobile" ? (
1750
- <div
1751
- className="redacto-assisted-phone-field"
1752
- style={styles.phoneField}
1753
- >
1754
- <span style={styles.phonePrefix}>{ASSISTED_CONFIG.dialCode}</span>
1755
- <input
1756
- id="redacto-assisted-mobile"
1757
- ref={mobileInputRef}
1758
- type="tel"
1759
- inputMode="numeric"
1760
- autoComplete="tel-national"
1761
- className="redacto-assisted-phone-input"
1762
- style={styles.phoneInput}
1763
- placeholder="98765 43210"
1764
- value={mobile}
1765
- disabled={otpSent || isSendingOtp}
1766
- onChange={(e: ChangeEvent<HTMLInputElement>) =>
1767
- setMobile(e.target.value.replace(/\D/g, "").slice(0, 10))
1768
- }
1769
- />
1770
- </div>
1771
- ) : (
1772
- <div
1773
- className="redacto-assisted-phone-field"
1774
- style={styles.phoneField}
1775
- >
1776
- <input
1777
- id="redacto-assisted-email"
1778
- ref={emailInputRef}
1779
- type="email"
1780
- inputMode="email"
1781
- autoComplete="email"
1782
- className="redacto-assisted-phone-input"
1783
- style={styles.phoneInput}
1784
- placeholder="you@example.com"
1785
- value={email}
1786
- disabled={otpSent || isSendingOtp}
1787
- onChange={(e: ChangeEvent<HTMLInputElement>) =>
1788
- setEmail(e.target.value)
1789
- }
1790
- />
1791
- </div>
1792
- )}
1793
-
1794
- {!otpSent ? (
1795
- <p style={styles.helperText}>
1796
- {verifyMethod === "email" ? ts("otpHelperEmail") : ts("otpHelper")}
1797
- </p>
1798
- ) : (
1799
- <div style={styles.otpStatusRow}>
1800
- <span style={styles.otpSentText}>
1801
- <CheckIcon size={15} color="#10B981" /> {ts("otpSentTo")}{" "}
1802
- {verifyMethod === "email"
1803
- ? email
1804
- : `${ASSISTED_CONFIG.dialCode} ${mobile}`}
1805
- </span>
1806
- <button
1807
- type="button"
1808
- style={{ ...styles.changeMobileLink, color: primaryColor }}
1809
- onClick={handleChangeContact}
1810
- disabled={isVerifying}
1811
- >
1812
- {verifyMethod === "email" ? ts("changeEmail") : ts("changeMobile")}
1813
- </button>
1814
- </div>
1815
- )}
1816
-
1817
- {createError && (
1818
- <p style={styles.fieldError} role="alert">
1819
- {createError}
1820
- </p>
1821
- )}
1822
-
1823
- {otpSent && (
1824
- <div
1825
- ref={otpSectionRef}
1826
- style={{ display: "flex", flexDirection: "column", gap: "12px" }}
1827
- >
1828
- <p style={styles.centerLabel}>
1829
- {ts("enterNDigitOtp", { count: ASSISTED_CONFIG.otpLength })}
1830
- </p>
1831
-
1832
- <div style={styles.otpRow}>
1833
- {otpDigits.map((digit, index) => (
1834
- <input
1835
- // OTP boxes are positional and never reorder.
1836
- // eslint-disable-next-line react/no-array-index-key
1837
- key={index}
1838
- ref={(el) => {
1839
- otpInputRefs.current[index] = el;
1840
- }}
1841
- type="text"
1842
- inputMode="numeric"
1843
- autoComplete="one-time-code"
1844
- maxLength={1}
1845
- className={`redacto-assisted-otp-input${
1846
- otpError ? " redacto-assisted-otp-error" : ""
1847
- }`}
1848
- style={{
1849
- ...styles.otpBox,
1850
- // Filled box — emphasised with the brand accent.
1851
- ...(digit
1852
- ? {
1853
- borderColor: primaryColor,
1854
- backgroundColor: accentTint,
1855
- }
1856
- : null),
1857
- // 3 + 3 grouping: a slightly larger gap before the 4th box.
1858
- ...(index === 3 ? { marginLeft: "8px" } : null),
1859
- }}
1860
- value={digit}
1861
- disabled={isVerifying}
1862
- onChange={(e) => handleOtpChange(index, e.target.value)}
1863
- onKeyDown={(e) => handleOtpKeyDown(index, e)}
1864
- onPaste={handleOtpPaste}
1865
- aria-label={ts("otpDigit", { index: index + 1 })}
1866
- />
1867
- ))}
1868
- </div>
1869
-
1870
- {otpError && (
1871
- <p style={styles.fieldError} role="alert">
1872
- {otpError}
1873
- </p>
1874
- )}
1875
-
1876
- <p style={styles.resendText}>
1877
- {resendCountdown > 0 ? (
1878
- <>
1879
- <ClockIcon />
1880
- {ts("resendIn", { seconds: resendCountdown })}
1881
- </>
1882
- ) : (
1883
- <button
1884
- type="button"
1885
- style={{ ...styles.resendLink, color: primaryColor }}
1886
- onClick={handleResend}
1887
- disabled={isSendingOtp}
1888
- >
1889
- {isSendingOtp ? ts("sending") : ts("resendOtp")}
1890
- </button>
1891
- )}
1892
- </p>
1893
- </div>
1894
- )}
1895
- </div>
1896
- </div>
1897
- );
1898
-
1899
- const renderFooter = () => {
1900
- const declineLabel = getTranslatedText(
1901
- "decline_button_text",
1902
- content?.decline_button_text || "Decline",
1903
- );
1904
-
1905
- const isVerify = step === "verify";
1906
- // Primary CTA progression: Confirm (notice) → Send OTP (mobile entry) →
1907
- // Confirm OTP (code entry). The notice's API label seeds the first state.
1908
- let primaryLabel = getTranslatedText(
1909
- "confirm_button_text",
1910
- content?.confirm_button_text || "Confirm",
1911
- );
1912
- let primaryDisabled = !areAllRequiredElementsChecked;
1913
- let primaryOnClick = handleConfirmStep;
1914
- if (isVerify && !otpSent) {
1915
- primaryLabel = isSendingOtp ? ts("sending") : ts("sendOtp");
1916
- primaryDisabled = !isContactValid || isSendingOtp;
1917
- primaryOnClick = handleGetOtp;
1918
- } else if (isVerify && otpSent) {
1919
- primaryLabel = isVerifying ? ts("verifying") : ts("confirmOtp");
1920
- primaryDisabled = !isOtpComplete || isVerifying;
1921
- primaryOnClick = handleConfirm;
1922
- }
1923
- return (
1924
- <div style={{ ...styles.bottomSection, ...responsive.bottomSection }}>
1925
- <button
1926
- type="button"
1927
- id="confirm-button-text"
1928
- className="redacto-assisted-primary-btn"
1929
- style={{
1930
- ...styles.button,
1931
- ...responsive.button,
1932
- ...styles.acceptButton,
1933
- backgroundColor: primaryColor,
1934
- }}
1935
- disabled={primaryDisabled}
1936
- onClick={primaryOnClick}
1937
- >
1938
- {primaryLabel}
1939
- </button>
1940
- <button
1941
- type="button"
1942
- id="decline-button-text"
1943
- className="redacto-assisted-secondary-btn"
1944
- style={{
1945
- ...styles.button,
1946
- ...responsive.button,
1947
- ...styles.cancelButton,
1948
- }}
1949
- onClick={handleDecline}
1950
- >
1951
- {declineLabel}
1952
- </button>
1953
- </div>
1954
- );
1955
- };
1956
-
1957
- const renderBody = () => {
1958
- if (isLoading) {
1959
- return (
1960
- <div style={styles.loadingWrap}>
1961
- <div style={styles.loadingSpinner} aria-label={ts("loadingNotice")} />
1962
- </div>
1963
- );
1964
- }
1965
- if (fetchError || !content) {
1966
- return (
1967
- <div style={styles.loadingWrap}>
1968
- <p style={styles.errorTitle}>{ts("loadFailedTitle")}</p>
1969
- <p style={styles.errorText}>
1970
- {fetchError?.message || ts("tryAgain")}
1971
- </p>
1972
- <button
1973
- type="button"
1974
- className="redacto-assisted-secondary-btn"
1975
- style={{ ...styles.button, ...styles.cancelButton, width: "auto" }}
1976
- onClick={handleDecline}
1977
- >
1978
- {ts("close")}
1979
- </button>
1980
- </div>
1981
- );
1982
- }
1983
- return (
1984
- <>
1985
- {renderHeader()}
1986
- <div style={{ ...styles.middleSection, ...responsive.middleSection }}>
1987
- {renderNoticeScreen(step === "verify")}
1988
- {step === "verify" && renderVerifyBlock()}
1989
- </div>
1990
- {renderFooter()}
1991
- </>
1992
- );
1993
- };
1994
-
1995
- return (
1996
- <div style={styles.overlay}>
1997
- <div
1998
- style={{
1999
- ...styles.modal,
2000
- ...responsive.modal,
2001
- fontFamily,
2002
- ...accentVars,
2003
- }}
2004
- role="dialog"
2005
- aria-modal="true"
2006
- aria-label={noticeTitle}
2007
- >
2008
- <div style={styles.content}>{renderBody()}</div>
2009
- </div>
2010
- </div>
2011
- );
2012
- };
2013
-
2014
- // Inline checkmark icon — avoids pulling an icon dependency into the SDK.
2015
- // Convert a #RGB / #RRGGBB brand colour to an rgba() string. Returns null for
2016
- // anything we can't parse (named colours, rgb(), gradients) so callers fall back.
2017
- const hexToRgba = (hex: string, alpha: number): string | null => {
2018
- if (typeof hex !== "string" || hex[0] !== "#") return null;
2019
- let h = hex.slice(1);
2020
- if (h.length === 3) {
2021
- h = h
2022
- .split("")
2023
- .map((c) => c + c)
2024
- .join("");
2025
- }
2026
- if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
2027
- const n = parseInt(h, 16);
2028
- return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
2029
- };
2030
-
2031
- // Bring a freshly-revealed element into view within the scrollable middle
2032
- // section. Honors prefers-reduced-motion by skipping the smooth animation.
2033
- const scrollIntoViewSoft = (el: HTMLElement | null) => {
2034
- if (!el || typeof el.scrollIntoView !== "function") return;
2035
- const reduce =
2036
- typeof window !== "undefined" &&
2037
- typeof window.matchMedia === "function" &&
2038
- window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2039
- el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "nearest" });
2040
- };
2041
-
2042
- const ClockIcon = ({
2043
- size = 13,
2044
- color = "#667085",
2045
- }: {
2046
- size?: number;
2047
- color?: string;
2048
- }) => (
2049
- <svg
2050
- width={size}
2051
- height={size}
2052
- viewBox="0 0 24 24"
2053
- fill="none"
2054
- stroke={color}
2055
- strokeWidth={2}
2056
- strokeLinecap="round"
2057
- strokeLinejoin="round"
2058
- aria-hidden="true"
2059
- style={{ flexShrink: 0 }}
2060
- >
2061
- <circle cx="12" cy="12" r="9" />
2062
- <path d="M12 7v5l3 2" />
2063
- </svg>
2064
- );
2065
-
2066
- const LockIcon = ({
2067
- size = 15,
2068
- color = "#475467",
2069
- }: {
2070
- size?: number;
2071
- color?: string;
2072
- }) => (
2073
- <svg
2074
- width={size}
2075
- height={size}
2076
- viewBox="0 0 24 24"
2077
- fill="none"
2078
- stroke={color}
2079
- strokeWidth={2}
2080
- strokeLinecap="round"
2081
- strokeLinejoin="round"
2082
- aria-hidden="true"
2083
- style={{ flexShrink: 0 }}
2084
- >
2085
- <rect x="3" y="11" width="18" height="11" rx="2" />
2086
- <path d="M7 11V7a5 5 0 0 1 10 0v4" />
2087
- </svg>
2088
- );
2089
-
2090
- const CheckIcon = ({
2091
- size = 16,
2092
- color = "#10B981",
2093
- }: {
2094
- size?: number;
2095
- color?: string;
2096
- }) => (
2097
- <svg
2098
- width={size}
2099
- height={size}
2100
- viewBox="0 0 24 24"
2101
- fill="none"
2102
- stroke={color}
2103
- strokeWidth={2.5}
2104
- strokeLinecap="round"
2105
- strokeLinejoin="round"
2106
- aria-hidden="true"
2107
- style={{ flexShrink: 0 }}
2108
- >
2109
- <path d="M20 6 9 17l-5-5" />
2110
- </svg>
2111
- );
2112
-
2113
- const PhoneIcon = ({
2114
- size = 14,
2115
- color = "#475467",
2116
- }: {
2117
- size?: number;
2118
- color?: string;
2119
- }) => (
2120
- <svg
2121
- width={size}
2122
- height={size}
2123
- viewBox="0 0 24 24"
2124
- fill="none"
2125
- stroke={color}
2126
- strokeWidth={2}
2127
- strokeLinecap="round"
2128
- strokeLinejoin="round"
2129
- aria-hidden="true"
2130
- style={{ flexShrink: 0 }}
2131
- >
2132
- <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z" />
2133
- </svg>
2134
- );
2135
-
2136
- const MailIcon = ({
2137
- size = 14,
2138
- color = "#475467",
2139
- }: {
2140
- size?: number;
2141
- color?: string;
2142
- }) => (
2143
- <svg
2144
- width={size}
2145
- height={size}
2146
- viewBox="0 0 24 24"
2147
- fill="none"
2148
- stroke={color}
2149
- strokeWidth={2}
2150
- strokeLinecap="round"
2151
- strokeLinejoin="round"
2152
- aria-hidden="true"
2153
- style={{ flexShrink: 0 }}
2154
- >
2155
- <rect x="2" y="4" width="20" height="16" rx="2" />
2156
- <path d="m22 7-10 5L2 7" />
2157
- </svg>
2158
- );
2159
-
2160
- export default RedactoNoticeAssisted;