@redacto.io/consent-sdk-react 1.4.0 → 2.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.
@@ -3,10 +3,12 @@ import type {
3
3
  ConsentContent,
4
4
  ConsentEventPayload,
5
5
  FetchConsentContentParams,
6
+ FetchTTSAudioUrlsParams,
6
7
  GuardianInfoPayload,
7
8
  RedactoJwtPayload,
8
9
  SubmitConsentEventParams,
9
10
  SubmitGuardianInfoParams,
11
+ TTSAudioUrlsResponse,
10
12
  } from "./types";
11
13
 
12
14
  const BASE_URL = "https://api.redacto.io/consent";
@@ -352,3 +354,68 @@ export const submitGuardianInfo = async ({
352
354
  throw error;
353
355
  }
354
356
  };
357
+
358
+ export const fetchTTSAudioUrls = async ({
359
+ accessToken,
360
+ baseUrl,
361
+ noticeUuid,
362
+ language,
363
+ signal, // AbortSignal for request cancellation
364
+ }: FetchTTSAudioUrlsParams & { signal?: AbortSignal }): Promise<TTSAudioUrlsResponse> => {
365
+ try {
366
+ // Validate required parameters
367
+ if (!accessToken || !noticeUuid || !language) {
368
+ throw new Error("accessToken, noticeUuid, and language are required");
369
+ }
370
+
371
+ const decodedToken = decodeTokenSafely(accessToken);
372
+ if (!decodedToken) {
373
+ throw new Error("Invalid access token");
374
+ }
375
+
376
+ const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
377
+ if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
378
+ throw new Error("Invalid token: missing organization or workspace UUID");
379
+ }
380
+
381
+ const apiBaseUrl = baseUrl || BASE_URL;
382
+
383
+ const url = `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/audio/${language}`;
384
+
385
+ const response = await fetch(url, {
386
+ method: "GET",
387
+ headers: {
388
+ Authorization: `Bearer ${accessToken}`,
389
+ Accept: "application/json",
390
+ "Content-Type": "application/json",
391
+ },
392
+ signal, // Add abort signal for request cancellation
393
+ });
394
+
395
+ if (!response.ok) {
396
+ let errorMessage = `Failed to fetch TTS audio URLs: ${response.status} ${response.statusText}`;
397
+
398
+ if (response.status === 401) {
399
+ errorMessage = "Unauthorized: Invalid or expired token";
400
+ } else if (response.status === 403) {
401
+ errorMessage = "Forbidden: Access denied";
402
+ } else if (response.status === 404) {
403
+ errorMessage = "Notice or audio not found";
404
+ } else if (response.status >= 500) {
405
+ errorMessage = "Server error: Please try again later";
406
+ }
407
+
408
+ const error = new Error(errorMessage) as Error & { status?: number };
409
+ error.status = response.status;
410
+ throw error;
411
+ }
412
+
413
+ const data: TTSAudioUrlsResponse = await response.json();
414
+
415
+ return data;
416
+ } catch (error) {
417
+ // Log error for debugging but don't expose sensitive information
418
+ console.error("Error fetching TTS audio URLs:", error instanceof Error ? error.message : error);
419
+ throw error;
420
+ }
421
+ };
@@ -262,3 +262,42 @@ export type PurposeItemProps = {
262
262
  getTranslatedText: (key: string, defaultText: string, itemId?: string) => string;
263
263
  isAlreadyConsented: boolean;
264
264
  };
265
+
266
+ // TTS Audio Types
267
+ export type PurposeAudioUrls = {
268
+ name_audio_url: string;
269
+ description_audio_url: string;
270
+ };
271
+
272
+ export type DataElementAudioUrls = {
273
+ name_audio_url: string;
274
+ };
275
+
276
+ export type TTSAudioUrlsResponse = {
277
+ code: number;
278
+ status: string;
279
+ detail: {
280
+ uuid: string;
281
+ language: string;
282
+ notice_text_audio_url: string;
283
+ additional_text_audio_url: string;
284
+ notice_summary_audio_url: string;
285
+ confirm_button_text_audio_url: string;
286
+ decline_button_text_audio_url: string;
287
+ privacy_policy_prefix_text_audio_url: string;
288
+ vendor_list_prefix_text_audio_url: string;
289
+ privacy_policy_anchor_text_audio_url: string;
290
+ vendors_list_anchor_text_audio_url: string;
291
+ purpose_section_heading_audio_url: string;
292
+ notice_banner_heading_audio_url: string;
293
+ purposes_audio: Record<string, PurposeAudioUrls>;
294
+ data_elements_audio: Record<string, DataElementAudioUrls>;
295
+ };
296
+ };
297
+
298
+ export type FetchTTSAudioUrlsParams = {
299
+ accessToken: string;
300
+ baseUrl?: string;
301
+ noticeUuid: string;
302
+ language: string;
303
+ };
@@ -96,20 +96,42 @@ export const injectCheckboxStyles = () => {
96
96
  outline-offset: 2px !important;
97
97
  }
98
98
 
99
- /* Text-to-Speech Highlight Styles */
99
+ /* TTS Highlight Styles - Visible highlighting without layout shift */
100
100
  .redacto-tts-highlight {
101
+ position: relative !important;
102
+ transition: background-color 0.3s ease, outline 0.3s ease !important;
103
+ }
104
+
105
+ /* Block-level elements - headings */
106
+ h2.redacto-tts-highlight,
107
+ h3.redacto-tts-highlight {
101
108
  background-color: #FFF9C4 !important;
102
- transition: background-color 0.3s ease !important;
103
- border-radius: 2px !important;
109
+ border-radius: 3px !important;
104
110
  padding: 2px 4px !important;
111
+ margin: -2px -4px !important;
105
112
  }
106
113
 
107
- /* Word-by-word highlighting */
108
- .redacto-tts-word-highlight {
114
+ /* Inline elements - spans, links */
115
+ span.redacto-tts-highlight,
116
+ a.redacto-tts-highlight {
109
117
  background-color: #FFF9C4 !important;
110
- transition: background-color 0.2s ease !important;
111
- border-radius: 2px !important;
112
- padding: 0 1px !important;
118
+ border-radius: 3px !important;
119
+ padding: 2px 4px !important;
120
+ margin: -2px -4px !important;
121
+ }
122
+
123
+ /* Paragraphs - handle carefully to avoid layout shift */
124
+ p.redacto-tts-highlight {
125
+ background-color: #FFF9C4 !important;
126
+ border-radius: 3px !important;
127
+ padding: 2px 4px !important;
128
+ margin: -2px -4px !important;
129
+ }
130
+
131
+ /* Buttons - use outline to keep button styles intact */
132
+ button.redacto-tts-highlight {
133
+ outline: 3px solid #FFF9C4 !important;
134
+ outline-offset: 2px !important;
113
135
  }
114
136
  `;
115
137
 
@@ -43,26 +43,3 @@ export type TranslationObject = {
43
43
  | Record<string, string>
44
44
  | undefined;
45
45
  };
46
-
47
- export type TextSegment = {
48
- text: string;
49
- elementId: string;
50
- elementRef?: React.RefObject<HTMLElement>;
51
- };
52
-
53
- export type UseTextToSpeechReturn = {
54
- isPlaying: boolean;
55
- isPaused: boolean;
56
- currentIndex: number;
57
- play: () => void;
58
- pause: () => void;
59
- stop: () => void;
60
- toggle: () => void;
61
- };
62
-
63
- export type WordSpan = {
64
- word: string;
65
- span: HTMLSpanElement;
66
- startIndex: number;
67
- endIndex: number;
68
- };
@@ -465,6 +465,11 @@ const RedactoConsentInlineComponent = ({
465
465
  tabIndex={0}
466
466
  aria-expanded={!collapsedPurposes[purpose.uuid]}
467
467
  aria-controls={`purpose-${purpose.uuid}`}
468
+ aria-label={`${collapsedPurposes[purpose.uuid] ? "Expand" : "Collapse"} ${getTranslatedText(
469
+ `purposes.name`,
470
+ purpose.name,
471
+ purpose.uuid
472
+ )} details`}
468
473
  onKeyDown={(e) => {
469
474
  if (e.key === "Enter" || e.key === " ") {
470
475
  e.preventDefault();
@@ -1,378 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
- import type { TextSegment, UseTextToSpeechReturn, WordSpan } from "./types";
3
-
4
- /**
5
- * Custom hook for text-to-speech functionality with synchronized word-by-word highlighting
6
- */
7
- export const useTextToSpeech = (
8
- segments: TextSegment[],
9
- language: string = "en"
10
- ): UseTextToSpeechReturn => {
11
- const [isPlaying, setIsPlaying] = useState(false);
12
- const [isPaused, setIsPaused] = useState(false);
13
- const [currentIndex, setCurrentIndex] = useState(0);
14
-
15
- const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
16
- const synthRef = useRef<SpeechSynthesis | null>(null);
17
- const currentSegmentRef = useRef<number>(0);
18
- const wordSpansRef = useRef<WordSpan[]>([]);
19
- const originalTextsRef = useRef<Map<string, string>>(new Map());
20
- const originalHTMLRef = useRef<Map<string, string>>(new Map());
21
- const currentWordIndexRef = useRef<number>(-1);
22
- const isInitializedRef = useRef<boolean>(false);
23
-
24
- // Initialize speech synthesis
25
- useEffect(() => {
26
- if (typeof window !== "undefined" && "speechSynthesis" in window) {
27
- synthRef.current = window.speechSynthesis;
28
- }
29
- }, []);
30
-
31
- // Cleanup on unmount
32
- useEffect(() => {
33
- return () => {
34
- cleanupWordSpans();
35
- if (synthRef.current) {
36
- synthRef.current.cancel();
37
- }
38
- };
39
- }, []);
40
-
41
- // Cleanup word spans and restore original text
42
- const cleanupWordSpans = useCallback(() => {
43
- wordSpansRef.current.forEach((wordSpan) => {
44
- if (wordSpan.span.parentNode) {
45
- wordSpan.span.classList.remove("redacto-tts-word-highlight");
46
- wordSpan.span.style.backgroundColor = "";
47
- }
48
- });
49
- wordSpansRef.current = [];
50
- currentWordIndexRef.current = -1;
51
- isInitializedRef.current = false;
52
-
53
- // Restore original HTML structure
54
- originalHTMLRef.current.forEach((originalHTML, elementId) => {
55
- const element = document.getElementById(elementId);
56
- if (element && originalHTML) {
57
- // Restore the original HTML structure
58
- element.innerHTML = originalHTML;
59
- }
60
- });
61
- originalHTMLRef.current.clear();
62
- originalTextsRef.current.clear();
63
- }, []);
64
-
65
- // Remove highlight from all words
66
- const removeAllHighlights = useCallback(() => {
67
- wordSpansRef.current.forEach((wordSpan) => {
68
- wordSpan.span.classList.remove("redacto-tts-word-highlight");
69
- wordSpan.span.style.backgroundColor = "";
70
- });
71
- }, []);
72
-
73
- // Create word spans for an element
74
- const createWordSpans = useCallback((elementId: string, text: string): WordSpan[] => {
75
- const element = document.getElementById(elementId);
76
- if (!element) return [];
77
-
78
- // Store original HTML structure if not already stored
79
- if (!originalHTMLRef.current.has(elementId)) {
80
- const originalHTML = element.innerHTML || "";
81
- originalHTMLRef.current.set(elementId, originalHTML);
82
- const originalText = element.textContent || element.innerText || "";
83
- originalTextsRef.current.set(elementId, originalText);
84
- }
85
-
86
- // Split text into words while preserving spaces
87
- const words = text.split(/(\s+)/);
88
- const wordSpans: WordSpan[] = [];
89
- let charIndex = 0;
90
-
91
- // Clear element content and rebuild with word spans
92
- element.innerHTML = "";
93
-
94
- words.forEach((word) => {
95
- if (word.trim()) {
96
- // Create span for word
97
- const span = document.createElement("span");
98
- span.textContent = word;
99
- span.style.display = "inline";
100
- span.style.transition = "background-color 0.2s ease";
101
- span.style.padding = "0 1px";
102
- element.appendChild(span);
103
-
104
- const startIndex = charIndex;
105
- const endIndex = charIndex + word.length;
106
- wordSpans.push({
107
- word,
108
- span,
109
- startIndex,
110
- endIndex,
111
- });
112
- } else if (word) {
113
- // Preserve whitespace
114
- element.appendChild(document.createTextNode(word));
115
- }
116
- charIndex += word.length;
117
- });
118
-
119
- return wordSpans;
120
- }, []);
121
-
122
- // Highlight a specific word by index
123
- const highlightWordByIndex = useCallback((wordIndex: number) => {
124
- if (wordIndex >= 0 && wordIndex < wordSpansRef.current.length) {
125
- removeAllHighlights();
126
- const wordSpan = wordSpansRef.current[wordIndex];
127
- wordSpan.span.classList.add("redacto-tts-word-highlight");
128
- wordSpan.span.style.backgroundColor = "#FFF9C4";
129
-
130
- // Scroll into view if needed
131
- wordSpan.span.scrollIntoView({
132
- behavior: "smooth",
133
- block: "center",
134
- inline: "nearest",
135
- });
136
- }
137
- }, [removeAllHighlights]);
138
-
139
- // Speak a single segment with word-by-word highlighting
140
- const speakSegment = useCallback(
141
- (index: number) => {
142
- if (!synthRef.current || index >= segments.length) {
143
- setIsPlaying(false);
144
- setIsPaused(false);
145
- setCurrentIndex(0);
146
- currentSegmentRef.current = 0;
147
- cleanupWordSpans();
148
- return;
149
- }
150
-
151
- const segment = segments[index];
152
- if (!segment.text.trim()) {
153
- // Skip empty segments
154
- speakSegment(index + 1);
155
- return;
156
- }
157
-
158
- setCurrentIndex(index);
159
- currentSegmentRef.current = index;
160
-
161
- // Create word spans for this segment if not already created
162
- // If element doesn't exist in DOM (e.g., collapsed), we'll still speak without highlighting
163
- if (!isInitializedRef.current || wordSpansRef.current.length === 0) {
164
- const wordSpans = createWordSpans(segment.elementId, segment.text);
165
- wordSpansRef.current = wordSpans;
166
- currentWordIndexRef.current = -1;
167
- isInitializedRef.current = true;
168
- }
169
-
170
- // If no word spans were created (element not in DOM), still speak the text
171
- // This ensures TTS works even when elements are collapsed/hidden
172
- if (wordSpansRef.current.length === 0) {
173
- // Element doesn't exist in DOM - speak without highlighting
174
- const utterance = new SpeechSynthesisUtterance(segment.text);
175
-
176
- // Set language
177
- const langCode = language === "English" || language === "en"
178
- ? "en-US"
179
- : language;
180
- utterance.lang = langCode;
181
-
182
- // Set voice properties
183
- utterance.rate = 1.0;
184
- utterance.pitch = 1.0;
185
- utterance.volume = 1.0;
186
-
187
- utterance.onend = () => {
188
- // Move to next segment
189
- const nextIndex = index + 1;
190
- currentSegmentRef.current = nextIndex;
191
- if (nextIndex < segments.length) {
192
- speakSegment(nextIndex);
193
- } else {
194
- // Finished all segments
195
- setIsPlaying(false);
196
- setIsPaused(false);
197
- setCurrentIndex(0);
198
- currentSegmentRef.current = 0;
199
- cleanupWordSpans();
200
- }
201
- };
202
-
203
- utterance.onerror = (event) => {
204
- console.error("Speech synthesis error:", event);
205
- setIsPlaying(false);
206
- setIsPaused(false);
207
- cleanupWordSpans();
208
- };
209
-
210
- utteranceRef.current = utterance;
211
- synthRef.current.speak(utterance);
212
- return;
213
- }
214
-
215
- const utterance = new SpeechSynthesisUtterance(segment.text);
216
-
217
- // Set language
218
- const langCode = language === "English" || language === "en"
219
- ? "en-US"
220
- : language;
221
- utterance.lang = langCode;
222
-
223
- // Set voice properties
224
- utterance.rate = 1.0;
225
- utterance.pitch = 1.0;
226
- utterance.volume = 1.0;
227
-
228
- // Handle word boundary events for word-by-word highlighting
229
- utterance.onboundary = (event) => {
230
- if (event.name === "word" && event.charIndex !== undefined) {
231
- // Find the word span that corresponds to this character index
232
- const wordIndex = wordSpansRef.current.findIndex(
233
- (ws) =>
234
- event.charIndex >= ws.startIndex &&
235
- event.charIndex < ws.endIndex
236
- );
237
-
238
- if (wordIndex !== -1) {
239
- currentWordIndexRef.current = wordIndex;
240
- highlightWordByIndex(wordIndex);
241
- }
242
- }
243
- };
244
-
245
- utterance.onstart = () => {
246
- // Highlight first word when speech starts
247
- if (currentWordIndexRef.current === -1 && wordSpansRef.current.length > 0) {
248
- highlightWordByIndex(0);
249
- currentWordIndexRef.current = 0;
250
- }
251
- };
252
-
253
- utterance.onend = () => {
254
- // Remove highlights
255
- removeAllHighlights();
256
-
257
- // Reset for next segment
258
- isInitializedRef.current = false;
259
- wordSpansRef.current = [];
260
- currentWordIndexRef.current = -1;
261
-
262
- // Move to next segment
263
- const nextIndex = index + 1;
264
- currentSegmentRef.current = nextIndex;
265
- if (nextIndex < segments.length) {
266
- speakSegment(nextIndex);
267
- } else {
268
- // Finished all segments
269
- setIsPlaying(false);
270
- setIsPaused(false);
271
- setCurrentIndex(0);
272
- currentSegmentRef.current = 0;
273
- cleanupWordSpans();
274
- }
275
- };
276
-
277
- utterance.onerror = (event) => {
278
- console.error("Speech synthesis error:", event);
279
- setIsPlaying(false);
280
- setIsPaused(false);
281
- cleanupWordSpans();
282
- };
283
-
284
- utteranceRef.current = utterance;
285
- synthRef.current.speak(utterance);
286
- },
287
- [segments, language, createWordSpans, highlightWordByIndex, removeAllHighlights, cleanupWordSpans]
288
- );
289
-
290
- // Play function
291
- const play = useCallback(() => {
292
- if (!synthRef.current || segments.length === 0) return;
293
-
294
- if (isPaused) {
295
- // Resume from where we paused using native resume
296
- try {
297
- setIsPaused(false);
298
- setIsPlaying(true);
299
-
300
- // Resume the paused utterance
301
- if (synthRef.current.paused) {
302
- synthRef.current.resume();
303
- } else {
304
- // If not paused, we might need to restart
305
- // This can happen if the utterance ended while paused
306
- const currentSegment = currentSegmentRef.current;
307
- if (currentSegment < segments.length) {
308
- isInitializedRef.current = false;
309
- speakSegment(currentSegment);
310
- }
311
- }
312
- } catch (error) {
313
- console.error("Error resuming speech:", error);
314
- // If resume fails, restart from current segment
315
- const currentSegment = currentSegmentRef.current;
316
- if (currentSegment < segments.length) {
317
- isInitializedRef.current = false;
318
- speakSegment(currentSegment);
319
- }
320
- }
321
- } else {
322
- // Start from beginning
323
- setIsPlaying(true);
324
- setIsPaused(false);
325
- currentSegmentRef.current = 0;
326
- currentWordIndexRef.current = -1;
327
- isInitializedRef.current = false;
328
- cleanupWordSpans();
329
- speakSegment(0);
330
- }
331
- }, [isPaused, segments, speakSegment, cleanupWordSpans]);
332
-
333
- // Pause function
334
- const pause = useCallback(() => {
335
- if (synthRef.current && isPlaying && !isPaused) {
336
- try {
337
- synthRef.current.pause();
338
- setIsPaused(true);
339
- } catch (error) {
340
- console.error("Error pausing speech:", error);
341
- // If pause fails, stop instead
342
- stop();
343
- }
344
- }
345
- }, [isPlaying, isPaused]);
346
-
347
- // Stop function
348
- const stop = useCallback(() => {
349
- if (synthRef.current) {
350
- synthRef.current.cancel();
351
- setIsPlaying(false);
352
- setIsPaused(false);
353
- setCurrentIndex(0);
354
- currentSegmentRef.current = 0;
355
- currentWordIndexRef.current = -1;
356
- cleanupWordSpans();
357
- }
358
- }, [cleanupWordSpans]);
359
-
360
- // Toggle play/pause
361
- const toggle = useCallback(() => {
362
- if (isPlaying && !isPaused) {
363
- pause();
364
- } else {
365
- play();
366
- }
367
- }, [isPlaying, isPaused, play, pause]);
368
-
369
- return {
370
- isPlaying,
371
- isPaused,
372
- currentIndex,
373
- play,
374
- pause,
375
- stop,
376
- toggle,
377
- };
378
- };