@redacto.io/consent-sdk-react 9.3.0-beta.1 → 9.4.0-beta.0

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