@redacto.io/consent-sdk-react 10.0.0-beta.8 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +17 -0
- package/README.md +141 -3
- package/dist/chunk-FEGTW73Q.mjs +182 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/fonts.d.mts +35 -0
- package/dist/fonts.d.ts +35 -0
- package/dist/fonts.js +183 -0
- package/dist/fonts.mjs +10 -0
- package/dist/index.d.mts +41 -4
- package/dist/index.d.ts +41 -4
- package/dist/index.js +881 -416
- package/dist/index.mjs +502 -253
- package/dist/montserrat.generated-NBTECYRW.mjs +22 -0
- package/dist/privacy-center.d.mts +51 -11
- package/dist/privacy-center.d.ts +51 -11
- package/dist/privacy-center.js +252 -136
- package/dist/privacy-center.mjs +251 -137
- package/dist/types-6Aj7faxL.d.mts +57 -0
- package/dist/types-6Aj7faxL.d.ts +57 -0
- package/package.json +10 -1
- package/.turbo/turbo-build.log +0 -22
- package/.turbo/turbo-test.log +0 -203
- package/.turbo/turbo-verify-pack.log +0 -6
- package/CHANGELOG.md +0 -343
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +0 -934
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +0 -2160
- package/src/RedactoNoticeAssisted/api/index.ts +0 -275
- package/src/RedactoNoticeAssisted/api/types.ts +0 -182
- package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeAssisted/constants.ts +0 -51
- package/src/RedactoNoticeAssisted/i18n.ts +0 -1408
- package/src/RedactoNoticeAssisted/index.ts +0 -1
- package/src/RedactoNoticeAssisted/injectStyles.ts +0 -251
- package/src/RedactoNoticeAssisted/styles.ts +0 -730
- package/src/RedactoNoticeAssisted/types.ts +0 -26
- package/src/RedactoNoticeAssisted/useMediaQuery.ts +0 -36
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +0 -1152
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +0 -4258
- package/src/RedactoNoticeConsent/api/index.ts +0 -512
- package/src/RedactoNoticeConsent/api/types.ts +0 -469
- package/src/RedactoNoticeConsent/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +0 -602
- package/src/RedactoNoticeConsent/components/index.ts +0 -7
- package/src/RedactoNoticeConsent/constants.ts +0 -20
- package/src/RedactoNoticeConsent/index.ts +0 -1
- package/src/RedactoNoticeConsent/injectStyles.ts +0 -151
- package/src/RedactoNoticeConsent/language-codes.ts +0 -95
- package/src/RedactoNoticeConsent/services/storage.ts +0 -122
- package/src/RedactoNoticeConsent/styles.ts +0 -409
- package/src/RedactoNoticeConsent/types.ts +0 -54
- package/src/RedactoNoticeConsent/useMediaQuery.ts +0 -36
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.test.tsx +0 -369
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -687
- package/src/RedactoNoticeConsentInline/api/index.ts +0 -106
- package/src/RedactoNoticeConsentInline/api/types.ts +0 -183
- package/src/RedactoNoticeConsentInline/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeConsentInline/index.ts +0 -1
- package/src/RedactoNoticeConsentInline/injectStyles.ts +0 -40
- package/src/RedactoNoticeConsentInline/styles.ts +0 -397
- package/src/RedactoNoticeConsentInline/types.ts +0 -51
- package/src/RedactoNoticeConsentInline/useMediaQuery.ts +0 -36
- package/src/RedactoPrivacyCenter/NotificationsPage.tsx +0 -182
- package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +0 -19
- package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +0 -156
- package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +0 -17
- package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +0 -79
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +0 -36
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +0 -177
- package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +0 -26
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +0 -58
- package/src/RedactoPrivacyCenter/api/actions.ts +0 -695
- package/src/RedactoPrivacyCenter/api/client.ts +0 -911
- package/src/RedactoPrivacyCenter/api/config.ts +0 -33
- package/src/RedactoPrivacyCenter/api/fetcher.ts +0 -260
- package/src/RedactoPrivacyCenter/api/orgBranding.ts +0 -16
- package/src/RedactoPrivacyCenter/api/upload.ts +0 -93
- package/src/RedactoPrivacyCenter/assets/character.svg +0 -31
- package/src/RedactoPrivacyCenter/assets/fileAttach.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/greenTick.svg +0 -4
- package/src/RedactoPrivacyCenter/assets/mail.svg +0 -3
- package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/sentImg.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +0 -9
- package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +0 -252
- package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +0 -239
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +0 -268
- package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +0 -218
- package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +0 -205
- package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +0 -107
- package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +0 -100
- package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +0 -339
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +0 -245
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +0 -28
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +0 -40
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +0 -62
- package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +0 -36
- package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +0 -38
- package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +0 -31
- package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +0 -79
- package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +0 -25
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +0 -96
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +0 -97
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +0 -156
- package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +0 -171
- package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +0 -23
- package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +0 -116
- package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +0 -43
- package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +0 -71
- package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +0 -469
- package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +0 -426
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +0 -1206
- package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +0 -218
- package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +0 -138
- package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +0 -7
- package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +0 -179
- package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +0 -318
- package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +0 -333
- package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +0 -102
- package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +0 -159
- package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +0 -189
- package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +0 -118
- package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +0 -66
- package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +0 -222
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +0 -331
- package/src/RedactoPrivacyCenter/components/Form.tsx +0 -1796
- package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +0 -174
- package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +0 -337
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +0 -334
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +0 -173
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +0 -18
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +0 -54
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +0 -57
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +0 -19
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +0 -92
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +0 -20
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +0 -58
- package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +0 -10
- package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +0 -112
- package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +0 -27
- package/src/RedactoPrivacyCenter/components/Pagination.tsx +0 -140
- package/src/RedactoPrivacyCenter/components/PrivacyCenterEmptyState.tsx +0 -76
- package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +0 -97
- package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +0 -454
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +0 -270
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +0 -95
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +0 -187
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +0 -25
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -21
- package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +0 -159
- package/src/RedactoPrivacyCenter/components/constant.ts +0 -52
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +0 -119
- package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +0 -203
- package/src/RedactoPrivacyCenter/components/types.ts +0 -123
- package/src/RedactoPrivacyCenter/components/utils.tsx +0 -10
- package/src/RedactoPrivacyCenter/config/routes.ts +0 -9
- package/src/RedactoPrivacyCenter/config/table.ts +0 -25
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +0 -160
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +0 -47
- package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +0 -52
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +0 -92
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +0 -181
- package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +0 -11
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +0 -96
- package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +0 -30
- package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +0 -80
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +0 -15
- package/src/RedactoPrivacyCenter/context/types.ts +0 -19
- package/src/RedactoPrivacyCenter/lib/api.ts +0 -759
- package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +0 -67
- package/src/RedactoPrivacyCenter/lib/constants.ts +0 -276
- package/src/RedactoPrivacyCenter/lib/i18n.ts +0 -81
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -102
- package/src/RedactoPrivacyCenter/lib/queries.ts +0 -141
- package/src/RedactoPrivacyCenter/lib/types.ts +0 -876
- package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +0 -37
- package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +0 -127
- package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +0 -85
- package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +0 -109
- package/src/RedactoPrivacyCenter/lib/utils.ts +0 -783
- package/src/RedactoPrivacyCenter/locales/as/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/en/translation.json +0 -357
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +0 -345
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/or/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/te/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +0 -342
- package/src/RedactoPrivacyCenter/styles/applyBrandingTheme.ts +0 -66
- package/src/RedactoPrivacyCenter/styles/base.css +0 -117
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +0 -3108
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +0 -831
- package/src/RedactoPrivacyCenter/styles/responsive.ts +0 -138
- package/src/RedactoPrivacyCenter/styles/variables.css +0 -96
- package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +0 -387
- package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +0 -468
- package/src/RedactoPrivacyCenter/table/index.ts +0 -2
- package/src/RedactoPrivacyCenter/types/images.d.ts +0 -29
- package/src/RedactoPrivacyCenter/ui/Footer.tsx +0 -69
- package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +0 -54
- package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +0 -93
- package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +0 -47
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +0 -83
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +0 -93
- package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +0 -48
- package/src/RedactoPrivacyCenter/ui/PCButton.tsx +0 -77
- package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +0 -55
- package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +0 -107
- package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +0 -35
- package/src/RedactoPrivacyCenter/ui/PCInput.tsx +0 -132
- package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +0 -31
- package/src/RedactoPrivacyCenter/ui/PCModal.tsx +0 -106
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +0 -206
- package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +0 -433
- package/src/RedactoPrivacyCenter/ui/PCTable.tsx +0 -149
- package/src/RedactoPrivacyCenter/ui/PCText.tsx +0 -41
- package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +0 -43
- package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +0 -37
- package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +0 -121
- package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +0 -25
- package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +0 -53
- package/src/RedactoPrivacyCenter/ui/index.ts +0 -18
- package/src/RedactoPrivacyCenter/utils/cn.ts +0 -28
- package/src/RedactoPrivacyCenter/utils/date.ts +0 -48
- package/src/RedactoPrivacyCenter/utils/status.ts +0 -111
- package/src/RedactoPrivacyCenter/utils/toast.ts +0 -130
- package/src/index.ts +0 -19
- package/src/privacy-center.ts +0 -113
- package/src/shared/api-errors.ts +0 -180
- package/src/shared/notice-consent-status.ts +0 -190
- package/src/types/css.d.ts +0 -4
- package/src/types/images.d.ts +0 -19
- package/src/types/react-jsx.d.ts +0 -26
- package/tests/DataAvailabilityContext.test.tsx +0 -123
- package/tests/Form.test.tsx +0 -1287
- package/tests/ManageConsentErrorMapping.test.tsx +0 -85
- package/tests/ProductSelectionSection.test.tsx +0 -328
- package/tests/RequestSubmit.test.tsx +0 -135
- package/tests/RevocablePurpose.test.tsx +0 -146
- package/tests/RevokeWarningMessage.test.tsx +0 -151
- package/tests/consentClient.test.ts +0 -217
- package/tests/manageConsentServer.test.ts +0 -38
- package/tests/mocks.ts +0 -257
- package/tests/resolveContactFromJwt.test.ts +0 -44
- package/tests/setup.ts +0 -16
- package/tsconfig.json +0 -16
- package/tsup.config.ts +0 -31
- 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;
|