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

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