ui-soxo-bootstrap-core 2.6.1-dev.3 → 2.6.1-dev.31

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 (68) hide show
  1. package/core/components/extra-info/extra-info-details.js +2 -2
  2. package/core/components/index.js +2 -11
  3. package/core/components/landing-api/landing-api.js +216 -18
  4. package/core/components/landing-api/landing-api.scss +22 -0
  5. package/core/components/license-management/license-alert.js +97 -0
  6. package/core/lib/Store.js +8 -4
  7. package/core/lib/components/global-header/global-header.js +217 -242
  8. package/core/lib/components/index.js +2 -2
  9. package/core/lib/components/sidemenu/sidemenu.js +19 -13
  10. package/core/lib/components/sidemenu/sidemenu.scss +1 -1
  11. package/core/lib/elements/basic/country-phone-input/country-phone-input.js +14 -9
  12. package/core/lib/elements/basic/dragabble-wrapper/draggable-wrapper.js +1 -1
  13. package/core/lib/elements/basic/menu-tree/menu-tree.js +26 -13
  14. package/core/lib/models/forms/components/form-creator/form-creator.js +525 -468
  15. package/core/lib/models/forms/components/form-creator/form-creator.scss +30 -26
  16. package/core/lib/models/menus/components/menu-list/menu-list.js +424 -467
  17. package/core/lib/models/process/components/process-dashboard/process-dashboard.js +469 -3
  18. package/core/lib/models/process/components/process-dashboard/process-dashboard.scss +4 -0
  19. package/core/lib/modules/generic/generic-list/ExportReactCSV.js +28 -2
  20. package/core/lib/pages/change-password/change-password.js +17 -24
  21. package/core/lib/pages/change-password/change-password.scss +45 -48
  22. package/core/lib/pages/login/commnication-mode-selection.js +2 -2
  23. package/core/lib/pages/login/login.js +53 -64
  24. package/core/lib/pages/login/login.scss +9 -0
  25. package/core/lib/pages/login/reset-password.js +17 -17
  26. package/core/lib/pages/login/reset-password.scss +10 -1
  27. package/core/lib/pages/profile/themes.json +4 -4
  28. package/core/lib/utils/api/api.utils.js +53 -45
  29. package/core/lib/utils/common/common.utils.js +49 -35
  30. package/core/lib/utils/generic/generic.utils.js +2 -1
  31. package/core/lib/utils/http/http.utils.js +33 -4
  32. package/core/lib/utils/index.js +4 -1
  33. package/core/models/base/base.js +7 -3
  34. package/core/models/core-scripts/core-scripts.js +147 -126
  35. package/core/models/doctor/components/doctor-add/doctor-add.js +9 -4
  36. package/core/models/menus/components/menu-add/menu-add.js +1 -1
  37. package/core/models/menus/components/menu-lists/menu-lists.js +53 -54
  38. package/core/models/menus/menus.js +49 -2
  39. package/core/models/roles/components/role-add/role-add.js +92 -59
  40. package/core/models/roles/components/role-list/role-list.js +1 -1
  41. package/core/models/staff/components/staff-add/staff-add.js +20 -32
  42. package/core/models/users/components/assign-role/assign-role.js +145 -50
  43. package/core/models/users/components/assign-role/assign-role.scss +209 -45
  44. package/core/models/users/components/assign-role/avatar-props.js +45 -0
  45. package/core/models/users/components/user-add/user-add.js +46 -55
  46. package/core/models/users/components/user-add/user-edit.js +25 -4
  47. package/core/models/users/users.js +9 -1
  48. package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.js +1 -1
  49. package/core/modules/reporting/components/reporting-dashboard/README.md +316 -0
  50. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.js +174 -0
  51. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.scss +76 -0
  52. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.js +90 -0
  53. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.test.js +74 -0
  54. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +448 -0
  55. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.test.js +199 -0
  56. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +195 -822
  57. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.scss +43 -0
  58. package/core/modules/reporting/components/reporting-dashboard/reporting-table.js +517 -0
  59. package/core/modules/steps/action-buttons.js +30 -16
  60. package/core/modules/steps/action-buttons.scss +55 -9
  61. package/core/modules/steps/chat-assistant.js +141 -0
  62. package/core/modules/steps/openai-realtime.js +275 -0
  63. package/core/modules/steps/readme.md +167 -0
  64. package/core/modules/steps/steps.js +1286 -60
  65. package/core/modules/steps/steps.scss +703 -86
  66. package/core/modules/steps/timeline.js +21 -19
  67. package/core/modules/steps/voice-navigation.js +709 -0
  68. package/package.json +2 -1
@@ -4,27 +4,293 @@
4
4
  * - Manages a multi-step, time-tracked process workflow.
5
5
  * - Dynamically renders step-specific components based on configuration.
6
6
  * - Tracks step and process durations with local persistence support.
7
- * - Supports step navigation (next, previous, skip, timeline, keyboard).
7
+ * - Supports step navigation (next, previous, skip, breadcrumb, keyboard).
8
+ * - Touchscreen support: horizontal swipe gestures navigate between steps
9
+ * and transient left/right arrow buttons fade in on touch for discovery.
8
10
  * - Handles process submission and optional chaining to the next process.
9
- * - Provides a collapsible timeline view and action controls.
11
+ * - Renders a single active step view with compact breadcrumb controls.
10
12
  */
11
- import React, { useEffect, useState } from 'react';
12
- import { Row, Col, Empty } from 'antd';
13
- import { Card } from './../../lib';
14
- import * as genericComponents from './../../lib';
13
+ import { ArrowLeftOutlined, ArrowRightOutlined, CompressOutlined, ExpandOutlined, SoundOutlined } from '@ant-design/icons';
14
+ import { Empty, Select, Spin, message } from 'antd';
15
15
  import moment from 'moment';
16
- import { Location } from './../../lib';
17
- import ActionButtons from './action-buttons';
16
+ import { useEffect, useRef, useState } from 'react';
17
+ import { ExternalWindow } from '../../components';
18
18
  import { Dashboard } from '../../models';
19
+ import * as genericComponents from './../../lib';
20
+ import { Button, Card, Location } from './../../lib';
21
+ import { createOpenAIRealtimeSession, hasOpenAIRealtimeCredentials } from './openai-realtime';
19
22
  import './steps.scss';
20
- import TimelinePanel from './timeline';
21
- import { ExternalWindow } from '../../components';
23
+
24
+ const TOUCH_NAV_HIDE_DELAY = 2800;
25
+ const SWIPE_DISTANCE_THRESHOLD = 60;
26
+ const SWIPE_VERTICAL_TOLERANCE = 80;
27
+
28
+ /**
29
+ * First-step CTA labels keyed by normalized process name.
30
+ * - Keys are the lowercased/trimmed process name returned by the backend.
31
+ * - Missing keys fall back to the generic 'Next' label at the call site.
32
+ * - Frozen so accidental mutation during render doesn't leak across renders.
33
+ */
34
+ const FIRST_STEP_LABELS = Object.freeze({
35
+ verification: 'Verify Profile',
36
+ consultation: 'Start Consultation',
37
+ });
38
+
39
+ const STEP_WELCOME_LINES = [
40
+ 'Welcome to your AI Automated Consultation process.',
41
+ 'You are in the right place for a smooth and guided health journey.',
42
+ 'This experience is designed to keep your consultation easy and stress-free.',
43
+ ];
44
+
45
+ const STEP_INTRO_LINES = [
46
+ 'In this process, we will walk you through a seamless and friendly AI interaction experience.',
47
+ 'Each step is simple, guided, and focused on helping you feel prepared.',
48
+ 'We will guide you through each stage so you always know what happens next.',
49
+ ];
50
+
51
+ const STEP_EXPECTATION_LINES = [
52
+ 'A care specialist may ask quick clarification questions to ensure your details are accurate.',
53
+ 'This step focuses on collecting clear inputs so the care team can support you faster.',
54
+ 'You can expect a guided workflow with minimal waiting and clear instructions.',
55
+ 'The goal in this step is to keep your consultation organized and easy to follow.',
56
+ ];
57
+
58
+ const STEP_COMFORT_LINES = [
59
+ 'Take your time. There is no rush and assistance is always available nearby.',
60
+ 'If anything feels unclear, the next prompt will guide you before you continue.',
61
+ 'You can pause and review information before moving to the next stage.',
62
+ 'Your responses here help personalize the rest of your consultation flow.',
63
+ ];
64
+
65
+ const STEP_TIP_LINES = [
66
+ 'Tip: Keep your previous reports or test details ready for quicker progress.',
67
+ 'Tip: Follow on-screen prompts one at a time for the smoothest experience.',
68
+ 'Tip: If you are unsure about a question, answer what you know and continue.',
69
+ 'Tip: Stay relaxed; this process is built to be simple and patient-friendly.',
70
+ ];
71
+
72
+ const VOICE_PROVIDER_OPTIONS = [
73
+ { label: 'Gemini', value: 'gemini' },
74
+ { label: 'ElevenLabs', value: 'elevenlabs' },
75
+ { label: 'OpenAI', value: 'openai' },
76
+ ];
77
+
78
+ const GEMINI_VOICE_OPTIONS = [{ label: 'Kore', value: 'Kore' }];
79
+ const DEFAULT_GEMINI_TTS_VOICE = process.env.GEMINI_TTS_VOICE || process.env.REACT_APP_GEMINI_TTS_VOICE || GEMINI_VOICE_OPTIONS[0].value;
80
+ const OPENAI_TTS_VOICE_OPTIONS = [
81
+ { label: 'Alloy', value: 'alloy' },
82
+ { label: 'Ash', value: 'ash' },
83
+ { label: 'Coral', value: 'coral' },
84
+ { label: 'Echo', value: 'echo' },
85
+ { label: 'Fable', value: 'fable' },
86
+ { label: 'Nova', value: 'nova' },
87
+ { label: 'Onyx', value: 'onyx' },
88
+ { label: 'Sage', value: 'sage' },
89
+ { label: 'Shimmer', value: 'shimmer' },
90
+ ];
91
+ const DEFAULT_OPENAI_TTS_VOICE =
92
+ process.env.OPENAI_TTS_VOICE ||
93
+ process.env.REACT_APP_OPENAI_TTS_VOICE ||
94
+ process.env.OPENAI_REALTIME_VOICE ||
95
+ process.env.REACT_APP_OPENAI_REALTIME_VOICE ||
96
+ OPENAI_TTS_VOICE_OPTIONS[0].value;
97
+
98
+ const ELEVENLABS_VOICE_OPTIONS = [
99
+ { label: 'Rachel', value: '21m00Tcm4TlvDq8ikWAM' },
100
+ { label: 'Adam', value: 'pNInz6obpgDQGcFmaJgB' },
101
+ { label: 'Bella', value: 'EXAVITQu4vr4xnSDxMaL' },
102
+ { label: 'Antoni', value: 'ErXwobaYiN019PkySvjV' },
103
+ { label: 'Josh', value: 'TxGEqnHWrfWFTfGW9XjX' },
104
+ ];
105
+ const DEFAULT_ELEVENLABS_VOICE_ID =
106
+ process.env.ELEVENLABS_VOICE_ID ||
107
+ process.env.ELEVEN_LABS_VOICE_ID ||
108
+ process.env.REACT_APP_ELEVENLABS_VOICE_ID ||
109
+ ELEVENLABS_VOICE_OPTIONS[0].value;
110
+
111
+ const SARVAM_VOICE_OPTIONS = [
112
+ { label: 'Anushka', value: 'anushka' },
113
+ { label: 'Manisha', value: 'manisha' },
114
+ { label: 'Vidya', value: 'vidya' },
115
+ { label: 'Arya', value: 'arya' },
116
+ { label: 'Karun', value: 'karun' },
117
+ { label: 'Hitesh', value: 'hitesh' },
118
+ ];
119
+
120
+ const ELEVENLABS_TTS_API_BASE_URL =
121
+ process.env.ELEVENLABS_TTS_API_BASE_URL || process.env.REACT_APP_ELEVENLABS_TTS_API_BASE_URL || 'https://api.elevenlabs.io/v1/text-to-speech';
122
+ const ELEVENLABS_MODEL_ID = process.env.ELEVENLABS_MODEL_ID || process.env.REACT_APP_ELEVENLABS_MODEL_ID || 'eleven_multilingual_v2';
123
+ const ELEVENLABS_OUTPUT_FORMAT = process.env.ELEVENLABS_OUTPUT_FORMAT || process.env.REACT_APP_ELEVENLABS_OUTPUT_FORMAT || 'mp3_44100_128';
124
+ const GEMINI_TTS_MODEL = process.env.GEMINI_TTS_MODEL || process.env.REACT_APP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-preview-tts';
125
+ const GEMINI_TTS_API_BASE_URL =
126
+ process.env.GEMINI_API_BASE_URL || process.env.REACT_APP_GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
127
+ const OPENAI_TTS_ENDPOINT = process.env.OPENAI_TTS_ENDPOINT || process.env.REACT_APP_OPENAI_TTS_ENDPOINT || 'https://api.openai.com/v1/audio/speech';
128
+ const OPENAI_TTS_MODEL = process.env.OPENAI_TTS_MODEL || process.env.REACT_APP_OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
129
+ const OPENAI_TTS_FORMAT = process.env.OPENAI_TTS_FORMAT || process.env.REACT_APP_OPENAI_TTS_FORMAT || 'mp3';
130
+ const SARVAM_TTS_ENDPOINT = process.env.SARVAM_TTS_ENDPOINT || process.env.REACT_APP_SARVAM_TTS_ENDPOINT || 'https://api.sarvam.ai/text-to-speech';
131
+ const SARVAM_TTS_MODEL = process.env.SARVAM_TTS_MODEL || process.env.REACT_APP_SARVAM_TTS_MODEL || 'bulbul:v2';
132
+ const SARVAM_TARGET_LANGUAGE_CODE = process.env.SARVAM_TARGET_LANGUAGE_CODE || process.env.REACT_APP_SARVAM_TARGET_LANGUAGE_CODE || 'en-IN';
133
+ const SARVAM_OUTPUT_AUDIO_CODEC = process.env.SARVAM_OUTPUT_AUDIO_CODEC || process.env.REACT_APP_SARVAM_OUTPUT_AUDIO_CODEC || 'wav';
134
+ const NARRATION_CONTROLS_ENABLED = false;
135
+
136
+ function getFromStorage(storageKey) {
137
+ if (typeof window === 'undefined' || !window.localStorage) {
138
+ return null;
139
+ }
140
+
141
+ try {
142
+ return window.localStorage.getItem(storageKey);
143
+ } catch (error) {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function getElevenLabsApiKey() {
149
+ return (
150
+ process.env.ELEVEN_LABS_KEY ||
151
+ process.env.ELEVENLABS_API_KEY ||
152
+ process.env.REACT_APP_ELEVEN_LABS_KEY ||
153
+ process.env.REACT_APP_ELEVENLABS_API_KEY ||
154
+ getFromStorage('eleven_labs_key') ||
155
+ getFromStorage('elevenlabs_api_key') ||
156
+ getFromStorage('ELEVEN_LABS_KEY') ||
157
+ getFromStorage('REACT_APP_ELEVEN_LABS_KEY') ||
158
+ getFromStorage('REACT_APP_ELEVENLABS_API_KEY')
159
+ );
160
+ }
161
+
162
+ function getGeminiApiKey() {
163
+ return (
164
+ process.env.GEMINI_API_KEY ||
165
+ process.env.REACT_APP_GEMINI_API_KEY ||
166
+ getFromStorage('gemini_api_key') ||
167
+ getFromStorage('GEMINI_API_KEY') ||
168
+ getFromStorage('REACT_APP_GEMINI_API_KEY')
169
+ );
170
+ }
171
+
172
+ function getOpenAIApiKey() {
173
+ return (
174
+ process.env.OPEN_AI_KEY ||
175
+ process.env.OPENAI_API_KEY ||
176
+ process.env.REACT_APP_OPEN_AI_KEY ||
177
+ process.env.REACT_APP_OPENAI_API_KEY ||
178
+ getFromStorage('open_ai_key') ||
179
+ getFromStorage('openai_api_key') ||
180
+ getFromStorage('OPEN_AI_KEY') ||
181
+ getFromStorage('OPENAI_API_KEY') ||
182
+ getFromStorage('REACT_APP_OPEN_AI_KEY') ||
183
+ getFromStorage('REACT_APP_OPENAI_API_KEY')
184
+ );
185
+ }
186
+
187
+ function getSarvamApiKey() {
188
+ return (
189
+ process.env.SARVAM_API_KEY ||
190
+ process.env.REACT_APP_SARVAM_API_KEY ||
191
+ getFromStorage('sarvam_api_key') ||
192
+ getFromStorage('REACT_APP_SARVAM_API_KEY')
193
+ );
194
+ }
195
+
196
+ function base64AudioToBlob(base64Audio = '', mimeType = 'audio/wav') {
197
+ const cleanedBase64 = base64Audio.includes(',') ? base64Audio.split(',').pop() : base64Audio;
198
+ const binaryString = typeof window !== 'undefined' && window.atob ? window.atob(cleanedBase64) : atob(cleanedBase64);
199
+ const bytes = new Uint8Array(binaryString.length);
200
+
201
+ for (let index = 0; index < binaryString.length; index += 1) {
202
+ bytes[index] = binaryString.charCodeAt(index);
203
+ }
204
+
205
+ return new Blob([bytes], { type: mimeType });
206
+ }
207
+
208
+ function extractGeminiAudio(payload) {
209
+ const candidates = payload && payload.candidates ? payload.candidates : [];
210
+
211
+ for (const candidate of candidates) {
212
+ const parts = candidate && candidate.content && candidate.content.parts ? candidate.content.parts : [];
213
+
214
+ for (const part of parts) {
215
+ const inlineData = part.inlineData || part.inline_data || part.audio;
216
+
217
+ if (inlineData && inlineData.data) {
218
+ return {
219
+ mimeType: inlineData.mimeType || inlineData.mime_type || 'audio/wav',
220
+ data: inlineData.data,
221
+ };
222
+ }
223
+ }
224
+ }
225
+
226
+ return null;
227
+ }
228
+
229
+ function hashText(value = '') {
230
+ let hash = 0;
231
+
232
+ for (let index = 0; index < value.length; index += 1) {
233
+ hash = (hash << 5) - hash + value.charCodeAt(index);
234
+ hash |= 0;
235
+ }
236
+
237
+ return Math.abs(hash);
238
+ }
239
+
240
+ function pickBySeed(items = [], seed = 0) {
241
+ if (!items.length) {
242
+ return '';
243
+ }
244
+
245
+ return items[seed % items.length];
246
+ }
247
+
248
+ function buildGuestStepGuide(step, index, total) {
249
+ if (!step) {
250
+ return {
251
+ welcome: STEP_WELCOME_LINES[0],
252
+ intro: STEP_INTRO_LINES[0],
253
+ expectation: 'We are preparing your consultation journey.',
254
+ comfort: STEP_COMFORT_LINES[0],
255
+ tip: STEP_TIP_LINES[0],
256
+ narration: '',
257
+ };
258
+ }
259
+
260
+ const stepName = step.step_name || `Step ${index + 1}`;
261
+ const seedSource = `${step.step_id || step.id || index}-${stepName}`;
262
+ const seed = hashText(seedSource);
263
+
264
+ const welcome = index === 0 ? STEP_WELCOME_LINES[0] : `Now entering ${stepName}.`;
265
+ const intro = index === 0 ? STEP_INTRO_LINES[0] : pickBySeed(STEP_INTRO_LINES, seed + 1);
266
+ const expectation = step.step_description || pickBySeed(STEP_EXPECTATION_LINES, seed + 2);
267
+ const comfort = pickBySeed(STEP_COMFORT_LINES, seed + 3);
268
+ const tip = pickBySeed(STEP_TIP_LINES, seed + 4);
269
+
270
+ return {
271
+ welcome,
272
+ intro,
273
+ expectation,
274
+ comfort,
275
+ tip,
276
+ narration: [
277
+ `Step ${index + 1} of ${total}. ${stepName}.`,
278
+ welcome,
279
+ intro,
280
+ `What to expect: ${expectation}`,
281
+ `Comfort note: ${comfort}`,
282
+ `Helpful tip: ${tip}`,
283
+ ].join(' '),
284
+ };
285
+ }
22
286
 
23
287
  export default function ProcessStepsPage({ match, CustomComponents = {}, ...props }) {
24
288
  const allComponents = { ...genericComponents, ...CustomComponents };
289
+ const GuestInfoComponent = allComponents.EntryInfo;
25
290
 
26
291
  const [loading, setLoading] = useState(false);
27
292
  const [steps, setSteps] = useState([]);
293
+ const [processName, setProcessName] = useState(null);
28
294
  const [activeStep, setActiveStep] = useState(0);
29
295
  const [isStepCompleted, setIsStepCompleted] = useState(false);
30
296
 
@@ -32,24 +298,100 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
32
298
  const [stepStartTime, setStepStartTime] = useState(null);
33
299
  const [processStartTime, setProcessStartTime] = useState(null);
34
300
  const [processTimings, setProcessTimings] = useState([]);
35
- const [timelineCollapsed, setTimelineCollapsed] = useState(true);
36
301
  const [showExternalWindow, setShowExternalWindow] = useState(false);
37
302
  const [externalWin, setExternalWin] = useState(null);
303
+ const [autoNarration, setAutoNarration] = useState(NARRATION_CONTROLS_ENABLED);
304
+ const [voiceProvider, setVoiceProvider] = useState(
305
+ process.env.REACT_APP_STEP_TTS_PROVIDER && process.env.REACT_APP_STEP_TTS_PROVIDER !== 'browser'
306
+ ? process.env.REACT_APP_STEP_TTS_PROVIDER
307
+ : 'gemini'
308
+ );
309
+ const [browserVoiceOptions, setBrowserVoiceOptions] = useState([]);
310
+ const [voiceSelections, setVoiceSelections] = useState({
311
+ browser: process.env.REACT_APP_STEP_BROWSER_VOICE || process.env.REACT_APP_STEP_TTS_VOICE || '',
312
+ gemini: DEFAULT_GEMINI_TTS_VOICE,
313
+ elevenlabs: DEFAULT_ELEVENLABS_VOICE_ID,
314
+ openai: DEFAULT_OPENAI_TTS_VOICE,
315
+ sarvam: process.env.REACT_APP_SARVAM_SPEAKER || SARVAM_VOICE_OPTIONS[0].value,
316
+ });
317
+ const [stepSlideDirection, setStepSlideDirection] = useState('forward');
318
+ const [showNextProcessAction, setShowNextProcessAction] = useState(false);
319
+ const [isStepFullscreen, setIsStepFullscreen] = useState(false);
320
+ const [realtimeStatus, setRealtimeStatus] = useState('idle');
321
+ const [isTouchDevice, setIsTouchDevice] = useState(false);
322
+ const [touchNavVisible, setTouchNavVisible] = useState(false);
323
+
324
+ const narrationUtteranceRef = useRef(null);
325
+ const narrationAudioRef = useRef(null);
326
+ const narrationAudioUrlRef = useRef(null);
327
+ const narrationFallbackNoticeRef = useRef(false);
328
+ const realtimeSessionRef = useRef(null);
329
+ const fullscreenViewportRef = useRef(null);
330
+ const touchStartRef = useRef(null);
331
+ const touchNavHideTimeoutRef = useRef(null);
38
332
 
39
333
  const urlParams = Location.search();
334
+ const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
40
335
  let processId = urlParams.processId;
41
336
  const [currentProcessId, setCurrentProcessId] = useState(processId);
42
337
  // Load process details based on the current process ID
43
338
  useEffect(() => {
44
339
  loadProcess(currentProcessId);
45
340
 
46
- const saved = localStorage.getItem(`processTimings_${currentProcessId}`);
47
- setProcessTimings(saved ? JSON.parse(saved) : []);
341
+ let savedTimings = [];
342
+ try {
343
+ const saved = localStorage.getItem(`processTimings_${currentProcessId}`);
344
+ if (saved) {
345
+ const parsed = JSON.parse(saved);
346
+ if (Array.isArray(parsed)) {
347
+ savedTimings = parsed;
348
+ }
349
+ }
350
+ } catch (error) {
351
+ console.warn('Unable to restore process timings from local storage.', error);
352
+ }
353
+
354
+ setProcessTimings(savedTimings);
48
355
 
49
356
  setProcessStartTime(Date.now());
50
357
  setStepStartTime(Date.now());
358
+ setShowNextProcessAction(false);
51
359
  }, [currentProcessId]);
52
360
 
361
+ /**
362
+ * Sync the loaded process name into the address bar.
363
+ * - Mirrors `processName` into a `process` query parameter so deep-links and
364
+ * refreshes carry the human-readable process label.
365
+ * - Uses `window.history.replaceState` to avoid a navigation event, which
366
+ * keeps React Router state and component instances stable.
367
+ * - Removes the param when `processName` is null/empty so stale values do
368
+ * not linger after a process clears.
369
+ */
370
+ useEffect(() => {
371
+ if (typeof window === 'undefined') {
372
+ return;
373
+ }
374
+
375
+ const params = new URLSearchParams(window.location.search);
376
+ const trimmedName = typeof processName === 'string' ? processName.trim() : '';
377
+
378
+ if (trimmedName) {
379
+ if (params.get('process') === trimmedName) {
380
+ return;
381
+ }
382
+ params.set('process', trimmedName);
383
+ } else {
384
+ if (!params.has('process')) {
385
+ return;
386
+ }
387
+ params.delete('process');
388
+ }
389
+
390
+ const search = params.toString();
391
+ const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash || ''}`;
392
+ window.history.replaceState(window.history.state, '', newUrl);
393
+ }, [processName]);
394
+
53
395
  //// Reset step start time whenever the active step changes
54
396
 
55
397
  useEffect(() => {
@@ -63,10 +405,110 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
63
405
  }
64
406
  }, [activeStep, steps]);
65
407
 
408
+ useEffect(() => {
409
+ if (steps[activeStep]?.order_seqtype !== 'E') {
410
+ setShowNextProcessAction(false);
411
+ }
412
+ }, [activeStep, steps]);
413
+
414
+ useEffect(() => {
415
+ if (typeof window === 'undefined' || !window.speechSynthesis) {
416
+ return undefined;
417
+ }
418
+
419
+ const updateBrowserVoices = () => {
420
+ const voices = window.speechSynthesis
421
+ .getVoices()
422
+ .map((voice) => ({
423
+ label: `${voice.name} (${voice.lang})`,
424
+ value: voice.voiceURI || voice.name,
425
+ }))
426
+ .sort((voiceA, voiceB) => voiceA.label.localeCompare(voiceB.label));
427
+
428
+ setBrowserVoiceOptions(voices);
429
+
430
+ if (voices.length) {
431
+ setVoiceSelections((oldSelections) => {
432
+ if (oldSelections.browser) {
433
+ return oldSelections;
434
+ }
435
+
436
+ return {
437
+ ...oldSelections,
438
+ browser: voices[0].value,
439
+ };
440
+ });
441
+ }
442
+ };
443
+
444
+ updateBrowserVoices();
445
+ if (typeof window.speechSynthesis.addEventListener === 'function') {
446
+ window.speechSynthesis.addEventListener('voiceschanged', updateBrowserVoices);
447
+ } else {
448
+ window.speechSynthesis.onvoiceschanged = updateBrowserVoices;
449
+ }
450
+
451
+ return () => {
452
+ if (typeof window.speechSynthesis.removeEventListener === 'function') {
453
+ window.speechSynthesis.removeEventListener('voiceschanged', updateBrowserVoices);
454
+ } else if (window.speechSynthesis.onvoiceschanged === updateBrowserVoices) {
455
+ window.speechSynthesis.onvoiceschanged = null;
456
+ }
457
+ };
458
+ }, []);
459
+
460
+ useEffect(() => {
461
+ narrationFallbackNoticeRef.current = false;
462
+ }, [voiceProvider]);
463
+
464
+ useEffect(() => {
465
+ const isSupportedProvider = VOICE_PROVIDER_OPTIONS.some((option) => option.value === voiceProvider);
466
+
467
+ if (!isSupportedProvider) {
468
+ setVoiceProvider('gemini');
469
+ }
470
+ }, [voiceProvider]);
471
+
472
+ useEffect(() => {
473
+ stopNarration();
474
+ }, [voiceProvider, voiceSelections.browser, voiceSelections.gemini, voiceSelections.elevenlabs, voiceSelections.openai, voiceSelections.sarvam]);
475
+
476
+ useEffect(() => {
477
+ const providerVoices =
478
+ voiceProvider === 'gemini'
479
+ ? GEMINI_VOICE_OPTIONS
480
+ : voiceProvider === 'elevenlabs'
481
+ ? ELEVENLABS_VOICE_OPTIONS
482
+ : voiceProvider === 'openai'
483
+ ? OPENAI_TTS_VOICE_OPTIONS
484
+ : SARVAM_VOICE_OPTIONS;
485
+
486
+ if (!providerVoices.length) {
487
+ return;
488
+ }
489
+
490
+ setVoiceSelections((oldSelections) => {
491
+ if (oldSelections[voiceProvider]) {
492
+ return oldSelections;
493
+ }
494
+
495
+ return {
496
+ ...oldSelections,
497
+ [voiceProvider]: providerVoices[0].value,
498
+ };
499
+ });
500
+ }, [voiceProvider, browserVoiceOptions]);
501
+
66
502
  // Save updated process timings to state and localStorage
67
503
  const saveTimings = (updated) => {
68
- setProcessTimings(updated);
69
- localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updated));
504
+ const safeTimings = Array.isArray(updated) ? updated : [];
505
+ setProcessTimings(safeTimings);
506
+
507
+ try {
508
+ localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(safeTimings));
509
+ } catch (error) {
510
+ console.warn('Unable to persist process timings to local storage.', error);
511
+ }
70
512
  };
71
513
  // Record time spent on the current step
72
514
 
@@ -81,7 +523,8 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
81
523
  const stepId = steps[activeStep].step_id;
82
524
  // Clone existing timings
83
525
 
84
- const updated = [...processTimings];
526
+ const previousTimings = Array.isArray(processTimings) ? processTimings : [];
527
+ const updated = [...previousTimings];
85
528
  const index = updated.findIndex((t) => t.step_id === stepId);
86
529
  // Create timing entry for the step
87
530
 
@@ -119,6 +562,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
119
562
  const result = await Dashboard.loadProcess(processId);
120
563
 
121
564
  setSteps(result?.data?.steps || []);
565
+ setProcessName(result?.data?.process_name ?? null);
122
566
  if (result?.data?.next_process_id) setNextProcessId(result.data);
123
567
  } catch (e) {
124
568
  console.error('Error loading process steps:', e);
@@ -153,7 +597,11 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
153
597
  const response = await Dashboard.processLog(payload);
154
598
 
155
599
  if (response.success) {
156
- localStorage.removeItem(`processTimings_${currentProcessId}`);
600
+ try {
601
+ localStorage.removeItem(`processTimings_${currentProcessId}`);
602
+ } catch (error) {
603
+ console.warn('Unable to clear process timings from local storage.', error);
604
+ }
157
605
  setProcessTimings([]);
158
606
  return true;
159
607
  }
@@ -173,9 +621,21 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
173
621
  * - Navigates to the specified step index.
174
622
  */
175
623
  const gotoStep = (index, status = 'completed') => {
624
+ if (!steps.length) {
625
+ return;
626
+ }
627
+
628
+ const nextIndex = Math.max(0, Math.min(index, steps.length - 1));
629
+
630
+ if (nextIndex === activeStep) {
631
+ return;
632
+ }
633
+
634
+ setStepSlideDirection(nextIndex > activeStep ? 'forward' : 'backward');
635
+
176
636
  const updated = recordStepTime(status);
177
637
  saveTimings(updated);
178
- setActiveStep(index);
638
+ setActiveStep(nextIndex);
179
639
  };
180
640
  /**
181
641
  * Navigate to the next step
@@ -194,7 +654,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
194
654
  */
195
655
  const handleSkip = () => gotoStep(activeStep + 1, 'skipped');
196
656
  /**
197
- * Timeline Navigation
657
+ * Breadcrumb Navigation
198
658
  * - Navigates directly to the selected step.
199
659
  * - Records timing data for the current step.
200
660
  */
@@ -226,6 +686,398 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
226
686
  setShowExternalWindow(true);
227
687
  }
228
688
  };
689
+
690
+ function clearNarrationAudio() {
691
+ if (narrationAudioRef.current) {
692
+ narrationAudioRef.current.pause();
693
+ narrationAudioRef.current.src = '';
694
+ narrationAudioRef.current = null;
695
+ }
696
+
697
+ if (narrationAudioUrlRef.current && typeof window !== 'undefined' && window.URL) {
698
+ window.URL.revokeObjectURL(narrationAudioUrlRef.current);
699
+ narrationAudioUrlRef.current = null;
700
+ }
701
+ }
702
+
703
+ function stopNarration() {
704
+ clearNarrationAudio();
705
+
706
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
707
+ window.speechSynthesis.cancel();
708
+ }
709
+
710
+ narrationUtteranceRef.current = null;
711
+ }
712
+
713
+ function buildRealtimeInstructions() {
714
+ const step = steps[activeStep];
715
+ const stepName = step?.step_name || `Step ${activeStep + 1}`;
716
+ const stepDescription = step?.step_description || 'No additional description.';
717
+
718
+ return [
719
+ 'You are a warm, concise healthcare concierge assisting a guest during a guided process.',
720
+ `Current step: ${stepName}.`,
721
+ `Step description: ${stepDescription}.`,
722
+ 'Answer in short, helpful sentences and keep the guest calm and informed.',
723
+ 'Avoid medical diagnosis or treatment advice.',
724
+ ].join(' ');
725
+ }
726
+
727
+ async function startRealtimeConversation() {
728
+ if (realtimeSessionRef.current) {
729
+ return;
730
+ }
731
+
732
+ const session = createOpenAIRealtimeSession({
733
+ instructions: buildRealtimeInstructions(),
734
+ onStatus: (status) => {
735
+ setRealtimeStatus(status);
736
+ },
737
+ onError: (error) => {
738
+ console.error('OpenAI Realtime error:', error);
739
+ message.error(error?.message || 'OpenAI Realtime connection failed.');
740
+ },
741
+ });
742
+
743
+ realtimeSessionRef.current = session;
744
+ try {
745
+ await session.connect();
746
+ } catch (error) {
747
+ realtimeSessionRef.current = null;
748
+ }
749
+ }
750
+
751
+ function stopRealtimeConversation() {
752
+ if (realtimeSessionRef.current) {
753
+ realtimeSessionRef.current.disconnect();
754
+ realtimeSessionRef.current = null;
755
+ }
756
+ setRealtimeStatus('idle');
757
+ }
758
+
759
+ function playAudioBlob(audioBlob) {
760
+ return new Promise((resolve, reject) => {
761
+ if (typeof window === 'undefined' || !window.Audio || !window.URL) {
762
+ reject(new Error('Audio playback is not available.'));
763
+ return;
764
+ }
765
+
766
+ const audioUrl = window.URL.createObjectURL(audioBlob);
767
+ const audio = new window.Audio(audioUrl);
768
+
769
+ narrationAudioRef.current = audio;
770
+ narrationAudioUrlRef.current = audioUrl;
771
+
772
+ const cleanup = () => {
773
+ if (narrationAudioRef.current === audio) {
774
+ narrationAudioRef.current = null;
775
+ }
776
+
777
+ if (narrationAudioUrlRef.current === audioUrl) {
778
+ window.URL.revokeObjectURL(audioUrl);
779
+ narrationAudioUrlRef.current = null;
780
+ }
781
+ };
782
+
783
+ audio.onended = () => {
784
+ cleanup();
785
+ resolve();
786
+ };
787
+
788
+ audio.onpause = () => {
789
+ cleanup();
790
+ resolve();
791
+ };
792
+
793
+ audio.onerror = () => {
794
+ cleanup();
795
+ reject(new Error('Audio playback failed.'));
796
+ };
797
+
798
+ audio.play().catch((error) => {
799
+ cleanup();
800
+ reject(error);
801
+ });
802
+ });
803
+ }
804
+
805
+ function speakWithBrowser(text) {
806
+ return new Promise((resolve, reject) => {
807
+ if (typeof window === 'undefined' || !window.speechSynthesis || !window.SpeechSynthesisUtterance) {
808
+ reject(new Error('Speech synthesis is not available.'));
809
+ return;
810
+ }
811
+
812
+ const utterance = new window.SpeechSynthesisUtterance(text);
813
+ utterance.lang = process.env.REACT_APP_STEP_TTS_LANG || 'en-US';
814
+
815
+ const rate = Number(process.env.REACT_APP_STEP_TTS_RATE || 1);
816
+ const pitch = Number(process.env.REACT_APP_STEP_TTS_PITCH || 1);
817
+
818
+ utterance.rate = Number.isFinite(rate) ? rate : 1;
819
+ utterance.pitch = Number.isFinite(pitch) ? pitch : 1;
820
+
821
+ const selectedBrowserVoice = voiceSelections.browser;
822
+ if (selectedBrowserVoice) {
823
+ const browserVoice = window.speechSynthesis.getVoices().find((voice) => (voice.voiceURI || voice.name) === selectedBrowserVoice);
824
+
825
+ if (browserVoice) {
826
+ utterance.voice = browserVoice;
827
+ }
828
+ }
829
+
830
+ utterance.onend = () => {
831
+ if (narrationUtteranceRef.current === utterance) {
832
+ narrationUtteranceRef.current = null;
833
+ }
834
+ resolve();
835
+ };
836
+
837
+ utterance.onerror = () => {
838
+ if (narrationUtteranceRef.current === utterance) {
839
+ narrationUtteranceRef.current = null;
840
+ }
841
+ reject(new Error('Browser narration failed.'));
842
+ };
843
+
844
+ narrationUtteranceRef.current = utterance;
845
+ window.speechSynthesis.speak(utterance);
846
+ });
847
+ }
848
+
849
+ async function synthesizeGeminiAudio(text) {
850
+ const apiKey = getGeminiApiKey();
851
+
852
+ if (!apiKey) {
853
+ throw new Error('Gemini API key is missing.');
854
+ }
855
+
856
+ const selectedVoiceName = voiceSelections.gemini || DEFAULT_GEMINI_TTS_VOICE;
857
+ const endpoint = `${GEMINI_TTS_API_BASE_URL}/models/${GEMINI_TTS_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`;
858
+ const response = await fetch(endpoint, {
859
+ method: 'POST',
860
+ headers: {
861
+ 'Content-Type': 'application/json',
862
+ },
863
+ body: JSON.stringify({
864
+ contents: [
865
+ {
866
+ role: 'user',
867
+ parts: [{ text }],
868
+ },
869
+ ],
870
+ generationConfig: {
871
+ responseModalities: ['AUDIO'],
872
+ speechConfig: {
873
+ voiceConfig: {
874
+ prebuiltVoiceConfig: {
875
+ voiceName: selectedVoiceName,
876
+ },
877
+ },
878
+ },
879
+ },
880
+ }),
881
+ });
882
+
883
+ if (!response.ok) {
884
+ throw new Error(`Gemini TTS request failed with status ${response.status}.`);
885
+ }
886
+
887
+ const payload = await response.json();
888
+ const audio = extractGeminiAudio(payload);
889
+
890
+ if (!audio || !audio.data) {
891
+ throw new Error('Gemini did not return audio data.');
892
+ }
893
+
894
+ return base64AudioToBlob(audio.data, audio.mimeType || 'audio/wav');
895
+ }
896
+
897
+ async function synthesizeOpenAIAudio(text) {
898
+ const apiKey = getOpenAIApiKey();
899
+
900
+ if (!apiKey) {
901
+ throw new Error('OpenAI API key is missing.');
902
+ }
903
+
904
+ const selectedVoice = voiceSelections.openai || DEFAULT_OPENAI_TTS_VOICE;
905
+ const response = await fetch(OPENAI_TTS_ENDPOINT, {
906
+ method: 'POST',
907
+ headers: {
908
+ 'Content-Type': 'application/json',
909
+ Authorization: `Bearer ${apiKey}`,
910
+ },
911
+ body: JSON.stringify({
912
+ model: OPENAI_TTS_MODEL,
913
+ voice: selectedVoice,
914
+ input: text,
915
+ response_format: OPENAI_TTS_FORMAT,
916
+ }),
917
+ });
918
+
919
+ if (!response.ok) {
920
+ throw new Error(`OpenAI TTS request failed with status ${response.status}.`);
921
+ }
922
+
923
+ return response.blob();
924
+ }
925
+
926
+ async function synthesizeElevenLabsAudio(text) {
927
+ const apiKey = getElevenLabsApiKey();
928
+
929
+ if (!apiKey) {
930
+ throw new Error('ElevenLabs API key is missing.');
931
+ }
932
+
933
+ const selectedVoiceId = voiceSelections.elevenlabs || DEFAULT_ELEVENLABS_VOICE_ID;
934
+ const endpoint = `${ELEVENLABS_TTS_API_BASE_URL}/${encodeURIComponent(selectedVoiceId)}/stream?output_format=${encodeURIComponent(
935
+ ELEVENLABS_OUTPUT_FORMAT
936
+ )}`;
937
+ const response = await fetch(endpoint, {
938
+ method: 'POST',
939
+ headers: {
940
+ 'Content-Type': 'application/json',
941
+ Accept: 'audio/mpeg',
942
+ 'xi-api-key': apiKey,
943
+ },
944
+ body: JSON.stringify({
945
+ text,
946
+ model_id: ELEVENLABS_MODEL_ID,
947
+ }),
948
+ });
949
+
950
+ if (!response.ok) {
951
+ throw new Error(`ElevenLabs TTS request failed with status ${response.status}.`);
952
+ }
953
+
954
+ return response.blob();
955
+ }
956
+
957
+ async function synthesizeSarvamAudio(text) {
958
+ const apiKey = getSarvamApiKey();
959
+
960
+ if (!apiKey) {
961
+ throw new Error('Sarvam API key is missing.');
962
+ }
963
+
964
+ const selectedSpeaker = voiceSelections.sarvam || SARVAM_VOICE_OPTIONS[0].value;
965
+ const response = await fetch(SARVAM_TTS_ENDPOINT, {
966
+ method: 'POST',
967
+ headers: {
968
+ 'Content-Type': 'application/json',
969
+ 'api-subscription-key': apiKey,
970
+ },
971
+ body: JSON.stringify({
972
+ text,
973
+ target_language_code: SARVAM_TARGET_LANGUAGE_CODE,
974
+ model: SARVAM_TTS_MODEL,
975
+ speaker: selectedSpeaker,
976
+ output_audio_codec: SARVAM_OUTPUT_AUDIO_CODEC,
977
+ }),
978
+ });
979
+
980
+ const payload = await response.json().catch(() => null);
981
+
982
+ if (!response.ok) {
983
+ throw new Error(`Sarvam TTS request failed with status ${response.status}.`);
984
+ }
985
+
986
+ const audioBase64 = payload?.audios?.[0];
987
+ if (!audioBase64) {
988
+ throw new Error('Sarvam did not return any audio data.');
989
+ }
990
+
991
+ const codec = (SARVAM_OUTPUT_AUDIO_CODEC || '').toLowerCase();
992
+ const mimeType = codec === 'mp3' ? 'audio/mpeg' : 'audio/wav';
993
+
994
+ return base64AudioToBlob(audioBase64, mimeType);
995
+ }
996
+
997
+ async function speakText(text) {
998
+ if (!text || typeof window === 'undefined') {
999
+ return;
1000
+ }
1001
+
1002
+ stopNarration();
1003
+
1004
+ if (voiceProvider === 'gemini') {
1005
+ const geminiAudio = await synthesizeGeminiAudio(text);
1006
+ await playAudioBlob(geminiAudio);
1007
+ return;
1008
+ }
1009
+
1010
+ if (voiceProvider === 'elevenlabs') {
1011
+ const elevenLabsAudio = await synthesizeElevenLabsAudio(text);
1012
+ await playAudioBlob(elevenLabsAudio);
1013
+ return;
1014
+ }
1015
+
1016
+ if (voiceProvider === 'openai') {
1017
+ const openAiAudio = await synthesizeOpenAIAudio(text);
1018
+ await playAudioBlob(openAiAudio);
1019
+ return;
1020
+ }
1021
+
1022
+ if (voiceProvider === 'sarvam') {
1023
+ const sarvamAudio = await synthesizeSarvamAudio(text);
1024
+ await playAudioBlob(sarvamAudio);
1025
+ return;
1026
+ }
1027
+
1028
+ throw new Error('Browser narration is disabled. Use Gemini, ElevenLabs, or OpenAI.');
1029
+ }
1030
+
1031
+ async function speakCurrentStep() {
1032
+ const step = steps[activeStep];
1033
+ const guide = buildGuestStepGuide(step, activeStep, steps.length);
1034
+
1035
+ try {
1036
+ await speakText(guide.narration);
1037
+ } catch (error) {
1038
+ if (!narrationFallbackNoticeRef.current) {
1039
+ const providerLabel =
1040
+ voiceProvider === 'gemini'
1041
+ ? 'Gemini'
1042
+ : voiceProvider === 'elevenlabs'
1043
+ ? 'ElevenLabs'
1044
+ : voiceProvider === 'openai'
1045
+ ? 'OpenAI'
1046
+ : 'Selected provider';
1047
+ message.warning(`${providerLabel} narration failed.`);
1048
+ narrationFallbackNoticeRef.current = true;
1049
+ }
1050
+
1051
+ message.error(error?.message || 'Unable to play narration for this step.');
1052
+ }
1053
+ }
1054
+
1055
+ async function toggleStepFullscreen() {
1056
+ if (typeof document === 'undefined') {
1057
+ return;
1058
+ }
1059
+
1060
+ const targetElement = fullscreenViewportRef.current;
1061
+
1062
+ if (!targetElement || !targetElement.requestFullscreen) {
1063
+ return;
1064
+ }
1065
+
1066
+ try {
1067
+ if (document.fullscreenElement === targetElement) {
1068
+ await document.exitFullscreen();
1069
+ return;
1070
+ }
1071
+
1072
+ if (document.fullscreenElement) {
1073
+ await document.exitFullscreen();
1074
+ }
1075
+
1076
+ await targetElement.requestFullscreen();
1077
+ } catch (error) {
1078
+ console.error('Failed to toggle step fullscreen mode:', error);
1079
+ }
1080
+ }
229
1081
  /**
230
1082
  * Dynamic Step Renderer
231
1083
  * - Resolves and renders step-specific components dynamically.
@@ -271,49 +1123,423 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
271
1123
  }, [activeStep, steps, externalWin]);
272
1124
 
273
1125
  /**
274
- * Renders the main process UI including timeline, step details,
1126
+ * Touch-device detection.
1127
+ * - Runs once on mount.
1128
+ * - Uses `matchMedia('(pointer: coarse)')` as the primary signal because it
1129
+ * targets the actual input hardware (covers touch laptops correctly) and
1130
+ * falls back to `ontouchstart` / `navigator.maxTouchPoints` for older
1131
+ * browsers.
1132
+ * - When neither signal matches, the effect bails out and `isTouchDevice`
1133
+ * stays false so desktop renders without any touch-only UI.
1134
+ */
1135
+ useEffect(() => {
1136
+ if (typeof window === 'undefined') {
1137
+ return undefined;
1138
+ }
1139
+
1140
+ const hasCoarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
1141
+ const hasTouch = 'ontouchstart' in window || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
1142
+
1143
+ if (!hasCoarsePointer && !hasTouch) {
1144
+ return undefined;
1145
+ }
1146
+
1147
+ setIsTouchDevice(true);
1148
+ }, []);
1149
+
1150
+ /**
1151
+ * Show the floating prev/next arrow buttons and reset their auto-hide timer.
1152
+ * - Any pending hide timeout is cleared so a rapid sequence of touches keeps
1153
+ * the arrows on-screen continuously instead of flickering.
1154
+ * - A fresh timeout is scheduled for TOUCH_NAV_HIDE_DELAY so the arrows fade
1155
+ * away once the user stops interacting, keeping the step content clear.
1156
+ */
1157
+ const revealTouchNav = () => {
1158
+ if (typeof window === 'undefined') {
1159
+ return;
1160
+ }
1161
+
1162
+ setTouchNavVisible(true);
1163
+
1164
+ if (touchNavHideTimeoutRef.current) {
1165
+ window.clearTimeout(touchNavHideTimeoutRef.current);
1166
+ }
1167
+
1168
+ touchNavHideTimeoutRef.current = window.setTimeout(() => {
1169
+ setTouchNavVisible(false);
1170
+ touchNavHideTimeoutRef.current = null;
1171
+ }, TOUCH_NAV_HIDE_DELAY);
1172
+ };
1173
+
1174
+ /**
1175
+ * onTouchStart for the stage body.
1176
+ * - Records the initial touch position so handleStageTouchEnd can measure
1177
+ * the swipe delta.
1178
+ * - Also reveals the side arrows immediately, giving the user a visible
1179
+ * navigation affordance as soon as they touch the screen.
1180
+ */
1181
+ const handleStageTouchStart = (event) => {
1182
+ if (!isTouchDevice || !event.touches || !event.touches.length) {
1183
+ return;
1184
+ }
1185
+
1186
+ const touch = event.touches[0];
1187
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
1188
+ revealTouchNav();
1189
+ };
1190
+
1191
+ /**
1192
+ * onTouchEnd for the stage body.
1193
+ * - Computes the horizontal/vertical delta against the stored touch origin.
1194
+ * - Ignores gestures that are vertical-dominant or below the distance
1195
+ * threshold, so normal scrolling and short taps are not hijacked.
1196
+ * - A left swipe advances to the next step (subject to the same
1197
+ * `isStepCompleted` / final-step rules as the visible Next button); a
1198
+ * right swipe goes back. Each successful swipe re-reveals the arrows so
1199
+ * the user can continue tapping if they prefer.
1200
+ */
1201
+ const handleStageTouchEnd = (event) => {
1202
+ const start = touchStartRef.current;
1203
+ touchStartRef.current = null;
1204
+
1205
+ if (!start || !event.changedTouches || !event.changedTouches.length) {
1206
+ return;
1207
+ }
1208
+
1209
+ const touch = event.changedTouches[0];
1210
+ const deltaX = touch.clientX - start.x;
1211
+ const deltaY = touch.clientY - start.y;
1212
+
1213
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
1214
+ return;
1215
+ }
1216
+ if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD) {
1217
+ return;
1218
+ }
1219
+ if (Math.abs(deltaY) > SWIPE_VERTICAL_TOLERANCE) {
1220
+ return;
1221
+ }
1222
+
1223
+ if (deltaX < 0) {
1224
+ const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1225
+ if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1226
+ handleNext();
1227
+ revealTouchNav();
1228
+ }
1229
+ } else if (activeStep > 0) {
1230
+ handlePrevious();
1231
+ revealTouchNav();
1232
+ }
1233
+ };
1234
+
1235
+ /**
1236
+ * Cleanup any pending auto-hide timeout on unmount so the callback cannot
1237
+ * fire against a stale component and emit a React warning.
1238
+ */
1239
+ useEffect(() => {
1240
+ return () => {
1241
+ if (typeof window !== 'undefined' && touchNavHideTimeoutRef.current) {
1242
+ window.clearTimeout(touchNavHideTimeoutRef.current);
1243
+ }
1244
+ };
1245
+ }, []);
1246
+
1247
+ useEffect(() => {
1248
+ if (typeof document === 'undefined') {
1249
+ return undefined;
1250
+ }
1251
+
1252
+ const handleFullscreenChange = () => {
1253
+ setIsStepFullscreen(document.fullscreenElement === fullscreenViewportRef.current);
1254
+ };
1255
+
1256
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
1257
+ handleFullscreenChange();
1258
+
1259
+ return () => {
1260
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
1261
+ };
1262
+ }, []);
1263
+
1264
+ useEffect(() => {
1265
+ if (!NARRATION_CONTROLS_ENABLED || !autoNarration) {
1266
+ return;
1267
+ }
1268
+
1269
+ if (loading || !steps.length || !steps[activeStep]) {
1270
+ return;
1271
+ }
1272
+
1273
+ speakCurrentStep();
1274
+ }, [
1275
+ activeStep,
1276
+ steps,
1277
+ loading,
1278
+ autoNarration,
1279
+ voiceProvider,
1280
+ voiceSelections.browser,
1281
+ voiceSelections.elevenlabs,
1282
+ voiceSelections.openai,
1283
+ voiceSelections.sarvam,
1284
+ ]);
1285
+
1286
+ useEffect(() => {
1287
+ const session = realtimeSessionRef.current;
1288
+ if (!session || session.status !== 'connected') {
1289
+ return;
1290
+ }
1291
+
1292
+ session.sendEvent({
1293
+ type: 'session.update',
1294
+ session: {
1295
+ instructions: buildRealtimeInstructions(),
1296
+ },
1297
+ });
1298
+ }, [activeStep, steps]);
1299
+
1300
+ useEffect(() => {
1301
+ return () => {
1302
+ stopNarration();
1303
+ stopRealtimeConversation();
1304
+ };
1305
+ }, []);
1306
+
1307
+ /**
1308
+ * Renders the main process UI including breadcrumb, step details,
275
1309
  * and action buttons. This content is reused in both normal view
276
1310
  * and external window view.
277
1311
  */
278
- const renderContent = () => (
279
- <div>
280
- <Card>
281
- <Row gutter={20}>
282
- <Col xs={24} sm={24} lg={timelineCollapsed ? 2 : 6}>
283
- <TimelinePanel
284
- loading={loading}
285
- steps={steps}
286
- activeStep={activeStep}
287
- timelineCollapsed={timelineCollapsed}
288
- handleTimelineClick={handleTimelineClick}
289
- setTimelineCollapsed={setTimelineCollapsed}
290
- />
291
- </Col>
292
-
293
- <Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
294
- <div style={{ marginBottom: 20 }}>
295
- <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{steps[activeStep]?.step_name}</h2>
296
- <p style={{ margin: 0, color: '#666' }}>{steps[activeStep]?.step_description}</p>
1312
+ const renderContent = () => {
1313
+ const currentStep = steps[activeStep];
1314
+ const isFinalStep = currentStep?.order_seqtype === 'E';
1315
+ const currentVoiceOptions =
1316
+ voiceProvider === 'gemini'
1317
+ ? GEMINI_VOICE_OPTIONS
1318
+ : voiceProvider === 'elevenlabs'
1319
+ ? ELEVENLABS_VOICE_OPTIONS
1320
+ : voiceProvider === 'openai'
1321
+ ? OPENAI_TTS_VOICE_OPTIONS
1322
+ : SARVAM_VOICE_OPTIONS;
1323
+ const currentVoiceValue = voiceSelections[voiceProvider] || undefined;
1324
+ const openAiTokenEndpoint = process.env.OPENAI_REALTIME_TOKEN_ENDPOINT || process.env.REACT_APP_OPENAI_REALTIME_TOKEN_ENDPOINT;
1325
+ const canStartRealtime = hasOpenAIRealtimeCredentials(openAiTokenEndpoint);
1326
+
1327
+ return (
1328
+ <div className="process-steps-page">
1329
+ <div ref={fullscreenViewportRef} className="steps-viewport">
1330
+ <Card className="steps-main-card">
1331
+ {/* {activeStep > 0 && GuestInfoComponent && (
1332
+ <div className="steps-patient-bar">
1333
+ <GuestInfoComponent params={urlParams} />
1334
+ </div>
1335
+ )} */}
1336
+
1337
+ <div className="steps-top-bar">
1338
+ <div className="steps-breadcrumb-strip">
1339
+ {steps.length ? (
1340
+ steps.map((stepItem, stepIndex) => {
1341
+ const isActiveBreadcrumb = stepIndex === activeStep;
1342
+ const isCompletedBreadcrumb = stepIndex < activeStep;
1343
+
1344
+ return (
1345
+ <button
1346
+ key={stepItem.step_id || `${stepItem.step_name || 'step'}_${stepIndex}`}
1347
+ type="button"
1348
+ className={`steps-breadcrumb-item${isActiveBreadcrumb ? ' active' : ''}${isCompletedBreadcrumb ? ' completed' : ''}`}
1349
+ onClick={() => handleTimelineClick(stepIndex)}
1350
+ >
1351
+ <span className="steps-breadcrumb-index">{stepIndex + 1}</span>
1352
+ <span className="steps-breadcrumb-label">{stepItem.step_name || `Step ${stepIndex + 1}`}</span>
1353
+ </button>
1354
+ );
1355
+ })
1356
+ ) : (
1357
+ <span className="steps-breadcrumb-empty">No steps loaded</span>
1358
+ )}
1359
+ </div>
1360
+
1361
+ <div className="steps-nav-actions">
1362
+ <Button type="dashed" icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
1363
+ {isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1364
+ </Button>
1365
+
1366
+ {activeStep > 0 && (
1367
+ <Button type="default" icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
1368
+ Back
1369
+ </Button>
1370
+ )}
1371
+
1372
+ {/* {activeStep > 0 && !isFinalStep && (
1373
+ <Button type="default" onClick={handleSkip}>
1374
+ Skip
1375
+ </Button>
1376
+ )} */}
1377
+
1378
+ {isFinalStep ? (
1379
+ <>
1380
+ {!showNextProcessAction && (
1381
+ <Button
1382
+ type="primary"
1383
+ onClick={async () => {
1384
+ const success = await handleFinish();
1385
+ if (success && nextProcessId?.next_process_id) {
1386
+ setShowNextProcessAction(true);
1387
+ }
1388
+ }}
1389
+ >
1390
+ Finish
1391
+ </Button>
1392
+ )}
1393
+ {showNextProcessAction && nextProcessId?.next_process_id && (
1394
+ <Button type="primary" onClick={handleStartNextProcess}>
1395
+ Start {nextProcessId.next_process_name} <ArrowRightOutlined />
1396
+ </Button>
1397
+ )}
1398
+ </>
1399
+ ) : (
1400
+ <Button type="primary" disabled={!isStepCompleted} onClick={handleNext}>
1401
+ {/*
1402
+ First-step label is resolved via FIRST_STEP_LABELS using
1403
+ the process name (lowercased + trimmed) as the key. Known
1404
+ processes get a tailored CTA (e.g. "Verify Profile",
1405
+ "Start Consultation"); unknown processes fall back to the
1406
+ generic "Next" label. All non-first steps always render
1407
+ "Next".
1408
+ */}
1409
+ {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}{' '}
1410
+ <ArrowRightOutlined />
1411
+ </Button>
1412
+ )}
1413
+ </div>
297
1414
  </div>
298
- <ActionButtons
299
- loading={loading}
300
- steps={steps}
301
- activeStep={activeStep}
302
- isStepCompleted={isStepCompleted}
303
- renderDynamicComponent={DynamicComponent}
304
- handlePrevious={handlePrevious}
305
- handleNext={handleNext}
306
- handleSkip={handleSkip}
307
- handleFinish={handleFinish}
308
- handleStartNextProcess={handleStartNextProcess}
309
- nextProcessId={nextProcessId}
310
- timelineCollapsed={timelineCollapsed}
311
- />
312
- </Col>
313
- </Row>
314
- </Card>
315
- </div>
316
- );
1415
+
1416
+ <div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1417
+ {/*
1418
+ Stage body:
1419
+ - `is-swipe-enabled` applies `touch-action: pan-y` so horizontal
1420
+ gestures reach our handlers while vertical scrolling remains
1421
+ native.
1422
+ - Touch handlers are only attached on touch devices to keep
1423
+ desktop event trees untouched.
1424
+ */}
1425
+ <div
1426
+ className={`steps-stage-body${isTouchDevice ? ' is-swipe-enabled' : ''}`}
1427
+ onTouchStart={isTouchDevice ? handleStageTouchStart : undefined}
1428
+ onTouchEnd={isTouchDevice ? handleStageTouchEnd : undefined}
1429
+ >
1430
+ {/*
1431
+ Floating prev/next arrow buttons.
1432
+ - Rendered only on touch devices; `is-visible` class drives
1433
+ the fade-in/out via CSS transitions.
1434
+ - Disabled states mirror the visible Next/Back buttons in the
1435
+ top bar: previous disabled on the first step; next disabled
1436
+ on the last/final step or when the current step still
1437
+ requires user completion (isStepCompleted === false).
1438
+ - Clicking either button reveals the arrows again so the
1439
+ auto-hide timer restarts after every interaction.
1440
+ */}
1441
+ {isTouchDevice ? (
1442
+ <>
1443
+ <button
1444
+ type="button"
1445
+ className={`steps-touch-nav steps-touch-nav-left${touchNavVisible ? ' is-visible' : ''}`}
1446
+ aria-label="Previous step"
1447
+ disabled={activeStep === 0}
1448
+ onClick={() => {
1449
+ revealTouchNav();
1450
+ if (activeStep > 0) handlePrevious();
1451
+ }}
1452
+ >
1453
+ <ArrowLeftOutlined />
1454
+ </button>
1455
+ <button
1456
+ type="button"
1457
+ className={`steps-touch-nav steps-touch-nav-right${touchNavVisible ? ' is-visible' : ''}`}
1458
+ aria-label="Next step"
1459
+ disabled={activeStep >= steps.length - 1 || steps[activeStep]?.order_seqtype === 'E' || !isStepCompleted}
1460
+ onClick={() => {
1461
+ revealTouchNav();
1462
+ const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1463
+ if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1464
+ handleNext();
1465
+ }
1466
+ }}
1467
+ >
1468
+ <ArrowRightOutlined />
1469
+ </button>
1470
+ </>
1471
+ ) : null}
1472
+ <div
1473
+ key={`${currentProcessId}_${activeStep}`}
1474
+ className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
1475
+ >
1476
+ {/* <div className="steps-chat-step-top">
1477
+ <span className="steps-index-pill">
1478
+ Step {Math.min(activeStep + 1, steps.length || 1)} of {steps.length || 1}
1479
+ </span>
1480
+ <h2 className="steps-title">{currentStep?.step_name || 'No step selected'}</h2>
1481
+ {currentStep?.step_description ? <p className="steps-description">{currentStep.step_description}</p> : null}
1482
+ </div> */}
1483
+
1484
+ <div className="steps-chat-step-component">
1485
+ {loading ? (
1486
+ <div className="steps-chat-loading">
1487
+ <Spin />
1488
+ </div>
1489
+ ) : null}
1490
+ {!loading ? <DynamicComponent /> : null}
1491
+ </div>
1492
+ </div>
1493
+ </div>
1494
+ </div>
1495
+
1496
+ {NARRATION_CONTROLS_ENABLED ? (
1497
+ <div className="steps-bottom-nav steps-narration-bar">
1498
+ <Select
1499
+ className="steps-voice-provider-select"
1500
+ value={voiceProvider}
1501
+ options={VOICE_PROVIDER_OPTIONS}
1502
+ onChange={(value) => setVoiceProvider(value)}
1503
+ />
1504
+ <Select
1505
+ className="steps-voice-select"
1506
+ value={currentVoiceValue}
1507
+ options={currentVoiceOptions}
1508
+ onChange={(value) =>
1509
+ setVoiceSelections((oldSelections) => ({
1510
+ ...oldSelections,
1511
+ [voiceProvider]: value,
1512
+ }))
1513
+ }
1514
+ placeholder="Select Voice"
1515
+ optionFilterProp="label"
1516
+ showSearch
1517
+ disabled={!currentVoiceOptions.length}
1518
+ />
1519
+ <Button icon={<SoundOutlined />} onClick={speakCurrentStep} disabled={!currentStep}>
1520
+ Read Step
1521
+ </Button>
1522
+ <Button
1523
+ type={realtimeStatus === 'connected' ? 'default' : 'primary'}
1524
+ disabled={!canStartRealtime}
1525
+ onClick={() => {
1526
+ if (realtimeStatus === 'connected' || realtimeStatus === 'connecting') {
1527
+ stopRealtimeConversation();
1528
+ return;
1529
+ }
1530
+ startRealtimeConversation();
1531
+ }}
1532
+ >
1533
+ {realtimeStatus === 'connected' ? 'Stop Conversation' : realtimeStatus === 'connecting' ? 'Connecting...' : 'Start Conversation'}
1534
+ </Button>
1535
+ <Button onClick={() => setAutoNarration((oldValue) => !oldValue)}>Auto Narration: {autoNarration ? 'On' : 'Off'}</Button>
1536
+ </div>
1537
+ ) : null}
1538
+ </Card>
1539
+ </div>
1540
+ </div>
1541
+ );
1542
+ };
317
1543
  /**
318
1544
  * Renders content in both the main window and an external window
319
1545
  * when external window mode is enabled.
@@ -333,9 +1559,9 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
333
1559
  width={props.ExternalWindowWidth || 1000}
334
1560
  height={props.ExternalWindowHeight || 1000}
335
1561
  >
336
- {renderContent()}
1562
+ {renderContent(false)}
337
1563
  </ExternalWindow>
338
- {renderContent()}
1564
+ {renderContent(true)}
339
1565
  </>
340
1566
  );
341
1567
  }