ui-soxo-bootstrap-core 2.6.32-dev.5 → 2.6.32-dev.7

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.
@@ -58,12 +58,7 @@ const TableComponent = ({ columns, dataSource, loading, fixed, scroll, summary,
58
58
 
59
59
  return (
60
60
  <Table
61
- title={() =>
62
- // <div style={{ fontWeight: 'bold', fontSize: 16, padding: '8px 16px' }}>
63
- title ? title : ''
64
- // </div>
65
- }
66
- // columns={updatedColumns}
61
+ title={title ? () => title : undefined}
67
62
  scroll={scroll}
68
63
  dataSource={dataSource}
69
64
  loading={loading}
@@ -32,7 +32,7 @@ const UserAdd = ({ model, callback, edit, history, formContent, match, additiona
32
32
  // for default branch
33
33
  // const [defaultBranch, setDefaultBranch] = useState(null);
34
34
  //Need to check this condition
35
- const [authentication, setAuthentication] = useState(false);
35
+ const [authentication, setAuthentication] = useState(true);
36
36
 
37
37
  /**To store user values */
38
38
  const [users, setUsers] = useState([]);
@@ -151,8 +151,10 @@ const UserAdd = ({ model, callback, edit, history, formContent, match, additiona
151
151
 
152
152
  /**if firmmas.otherdetails - 2FA == OPT, then, 2FA option will be enabled by default and editable */
153
153
  } else if (firmDetails.FA == 'USR') {
154
- setAuthentication(true);
155
-
154
+ /** If user has FA set to false , then disable authentication */
155
+ if (formContent?.FA !== undefined) {
156
+ setAuthentication(formContent?.FA);
157
+ }
156
158
  setDisabled(true);
157
159
 
158
160
  /**if firmmas.otherdetails - 2FA == NAP, then, 2FA option will be disabled by default and read-only*/
@@ -161,10 +163,6 @@ const UserAdd = ({ model, callback, edit, history, formContent, match, additiona
161
163
 
162
164
  setDisabled(true);
163
165
  }
164
- /** If user has FA set to false , then disable authentication */
165
- if (formContent?.FA === false) {
166
- setAuthentication(false);
167
- }
168
166
  }
169
167
  }, []);
170
168
 
@@ -387,7 +385,8 @@ const UserAdd = ({ model, callback, edit, history, formContent, match, additiona
387
385
  ...values,
388
386
  auth_type: 'LDAP',
389
387
  mobile: mobileWithCountryCode,
390
- FA: authentication,
388
+ FA: formContent?.FA === false ? false : authentication,
389
+
391
390
  };
392
391
 
393
392
  setLoading(true);
@@ -400,7 +399,7 @@ const UserAdd = ({ model, callback, edit, history, formContent, match, additiona
400
399
  addAllBranches: props.ldap.addAllBranches,
401
400
  auth_user: selectedOption.value,
402
401
  auth_type: 'LDAP',
403
- FA: authentication,
402
+ FA: formContent?.FA === false ? false : authentication,
404
403
  };
405
404
  }
406
405
 
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Narration helpers for ProcessStepsPage.
3
+ *
4
+ * Owns the per-step "guest guide" copy plus the credential lookup, audio
5
+ * decoding, and seed-based content picking used by the TTS providers. The
6
+ * step component imports from here so that file can stay focused on
7
+ * component lifecycle and layout.
8
+ */
9
+
10
+ const STEP_WELCOME_LINES = [
11
+ 'Welcome to your AI Automated Consultation process.',
12
+ 'You are in the right place for a smooth and guided health journey.',
13
+ 'This experience is designed to keep your consultation easy and stress-free.',
14
+ ];
15
+
16
+ const STEP_INTRO_LINES = [
17
+ 'In this process, we will walk you through a seamless and friendly AI interaction experience.',
18
+ 'Each step is simple, guided, and focused on helping you feel prepared.',
19
+ 'We will guide you through each stage so you always know what happens next.',
20
+ ];
21
+
22
+ const STEP_EXPECTATION_LINES = [
23
+ 'A care specialist may ask quick clarification questions to ensure your details are accurate.',
24
+ 'This step focuses on collecting clear inputs so the care team can support you faster.',
25
+ 'You can expect a guided workflow with minimal waiting and clear instructions.',
26
+ 'The goal in this step is to keep your consultation organized and easy to follow.',
27
+ ];
28
+
29
+ const STEP_COMFORT_LINES = [
30
+ 'Take your time. There is no rush and assistance is always available nearby.',
31
+ 'If anything feels unclear, the next prompt will guide you before you continue.',
32
+ 'You can pause and review information before moving to the next stage.',
33
+ 'Your responses here help personalize the rest of your consultation flow.',
34
+ ];
35
+
36
+ const STEP_TIP_LINES = [
37
+ 'Tip: Keep your previous reports or test details ready for quicker progress.',
38
+ 'Tip: Follow on-screen prompts one at a time for the smoothest experience.',
39
+ 'Tip: If you are unsure about a question, answer what you know and continue.',
40
+ 'Tip: Stay relaxed; this process is built to be simple and patient-friendly.',
41
+ ];
42
+
43
+ function getFromStorage(storageKey) {
44
+ if (typeof window === 'undefined' || !window.localStorage) {
45
+ return null;
46
+ }
47
+
48
+ try {
49
+ return window.localStorage.getItem(storageKey);
50
+ } catch (error) {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function getElevenLabsApiKey() {
56
+ return (
57
+ process.env.ELEVEN_LABS_KEY ||
58
+ process.env.ELEVENLABS_API_KEY ||
59
+ process.env.REACT_APP_ELEVEN_LABS_KEY ||
60
+ process.env.REACT_APP_ELEVENLABS_API_KEY ||
61
+ getFromStorage('eleven_labs_key') ||
62
+ getFromStorage('elevenlabs_api_key') ||
63
+ getFromStorage('ELEVEN_LABS_KEY') ||
64
+ getFromStorage('REACT_APP_ELEVEN_LABS_KEY') ||
65
+ getFromStorage('REACT_APP_ELEVENLABS_API_KEY')
66
+ );
67
+ }
68
+
69
+ export function getGeminiApiKey() {
70
+ return (
71
+ process.env.GEMINI_API_KEY ||
72
+ process.env.REACT_APP_GEMINI_API_KEY ||
73
+ getFromStorage('gemini_api_key') ||
74
+ getFromStorage('GEMINI_API_KEY') ||
75
+ getFromStorage('REACT_APP_GEMINI_API_KEY')
76
+ );
77
+ }
78
+
79
+ export function getOpenAIApiKey() {
80
+ return (
81
+ process.env.OPEN_AI_KEY ||
82
+ process.env.OPENAI_API_KEY ||
83
+ process.env.REACT_APP_OPEN_AI_KEY ||
84
+ process.env.REACT_APP_OPENAI_API_KEY ||
85
+ getFromStorage('open_ai_key') ||
86
+ getFromStorage('openai_api_key') ||
87
+ getFromStorage('OPEN_AI_KEY') ||
88
+ getFromStorage('OPENAI_API_KEY') ||
89
+ getFromStorage('REACT_APP_OPEN_AI_KEY') ||
90
+ getFromStorage('REACT_APP_OPENAI_API_KEY')
91
+ );
92
+ }
93
+
94
+ export function getSarvamApiKey() {
95
+ return (
96
+ process.env.SARVAM_API_KEY ||
97
+ process.env.REACT_APP_SARVAM_API_KEY ||
98
+ getFromStorage('sarvam_api_key') ||
99
+ getFromStorage('REACT_APP_SARVAM_API_KEY')
100
+ );
101
+ }
102
+
103
+ export function base64AudioToBlob(base64Audio = '', mimeType = 'audio/wav') {
104
+ const cleanedBase64 = base64Audio.includes(',') ? base64Audio.split(',').pop() : base64Audio;
105
+ const binaryString = typeof window !== 'undefined' && window.atob ? window.atob(cleanedBase64) : atob(cleanedBase64);
106
+ const bytes = new Uint8Array(binaryString.length);
107
+
108
+ for (let index = 0; index < binaryString.length; index += 1) {
109
+ bytes[index] = binaryString.charCodeAt(index);
110
+ }
111
+
112
+ return new Blob([bytes], { type: mimeType });
113
+ }
114
+
115
+ export function extractGeminiAudio(payload) {
116
+ const candidates = payload && payload.candidates ? payload.candidates : [];
117
+
118
+ for (const candidate of candidates) {
119
+ const parts = candidate && candidate.content && candidate.content.parts ? candidate.content.parts : [];
120
+
121
+ for (const part of parts) {
122
+ const inlineData = part.inlineData || part.inline_data || part.audio;
123
+
124
+ if (inlineData && inlineData.data) {
125
+ return {
126
+ mimeType: inlineData.mimeType || inlineData.mime_type || 'audio/wav',
127
+ data: inlineData.data,
128
+ };
129
+ }
130
+ }
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ function hashText(value = '') {
137
+ let hash = 0;
138
+
139
+ for (let index = 0; index < value.length; index += 1) {
140
+ hash = (hash << 5) - hash + value.charCodeAt(index);
141
+ hash |= 0;
142
+ }
143
+
144
+ return Math.abs(hash);
145
+ }
146
+
147
+ function pickBySeed(items = [], seed = 0) {
148
+ if (!items.length) {
149
+ return '';
150
+ }
151
+
152
+ return items[seed % items.length];
153
+ }
154
+
155
+ export function buildGuestStepGuide(step, index, total) {
156
+ if (!step) {
157
+ return {
158
+ welcome: STEP_WELCOME_LINES[0],
159
+ intro: STEP_INTRO_LINES[0],
160
+ expectation: 'We are preparing your consultation journey.',
161
+ comfort: STEP_COMFORT_LINES[0],
162
+ tip: STEP_TIP_LINES[0],
163
+ narration: '',
164
+ };
165
+ }
166
+
167
+ const stepName = step.step_name || `Step ${index + 1}`;
168
+ const seedSource = `${step.step_id || step.id || index}-${stepName}`;
169
+ const seed = hashText(seedSource);
170
+
171
+ const welcome = index === 0 ? STEP_WELCOME_LINES[0] : `Now entering ${stepName}.`;
172
+ const intro = index === 0 ? STEP_INTRO_LINES[0] : pickBySeed(STEP_INTRO_LINES, seed + 1);
173
+ const expectation = step.step_description || pickBySeed(STEP_EXPECTATION_LINES, seed + 2);
174
+ const comfort = pickBySeed(STEP_COMFORT_LINES, seed + 3);
175
+ const tip = pickBySeed(STEP_TIP_LINES, seed + 4);
176
+
177
+ return {
178
+ welcome,
179
+ intro,
180
+ expectation,
181
+ comfort,
182
+ tip,
183
+ narration: [
184
+ `Step ${index + 1} of ${total}. ${stepName}.`,
185
+ welcome,
186
+ intro,
187
+ `What to expect: ${expectation}`,
188
+ `Comfort note: ${comfort}`,
189
+ `Helpful tip: ${tip}`,
190
+ ].join(' '),
191
+ };
192
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Per-guest progress persistence helpers for ProcessStepsPage.
3
+ *
4
+ * Storage shape:
5
+ * key = `processTimings_<processId>_<guestRef>` or
6
+ * `processActiveStep_<processId>_<guestRef>`
7
+ * value = `{ savedAt: <ms>, value: { guestRef, ... } }`
8
+ *
9
+ * The key scopes by guest AND the inner value carries the guest reference,
10
+ * so reads can reject any entry that does not belong to the current guest —
11
+ * guest A's progress can never be applied to guest B even if a key collision
12
+ * somehow occurred.
13
+ *
14
+ * The TTL covers a working shift: a guest who walks away in the morning is
15
+ * not coming back the same evening to resume. The sweep on mount drops any
16
+ * entry past the TTL or stored in the legacy unwrapped shape.
17
+ */
18
+
19
+ export const PROGRESS_STORAGE_TTL_MS = 12 * 60 * 60 * 1000;
20
+ export const PROGRESS_STORAGE_PREFIXES = ['processTimings_', 'processActiveStep_'];
21
+
22
+ function hasLocalStorage() {
23
+ return typeof window !== 'undefined' && !!window.localStorage;
24
+ }
25
+
26
+ /**
27
+ * Drop progress keys that are past their TTL or stored in the legacy shape
28
+ * (which has no `savedAt` and therefore an unknowable age). Safe to call
29
+ * during mount and again after a quota error to free room for a retry.
30
+ */
31
+ export function sweepStaleProgressKeys() {
32
+ if (!hasLocalStorage()) {
33
+ return;
34
+ }
35
+
36
+ try {
37
+ const cutoff = Date.now() - PROGRESS_STORAGE_TTL_MS;
38
+ const toRemove = [];
39
+
40
+ for (let index = 0; index < window.localStorage.length; index += 1) {
41
+ const key = window.localStorage.key(index);
42
+ if (!key) {
43
+ continue;
44
+ }
45
+ if (!PROGRESS_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) {
46
+ continue;
47
+ }
48
+
49
+ try {
50
+ const raw = window.localStorage.getItem(key);
51
+ if (!raw) {
52
+ continue;
53
+ }
54
+ const parsed = JSON.parse(raw);
55
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.savedAt !== 'number' || parsed.savedAt < cutoff) {
56
+ toRemove.push(key);
57
+ }
58
+ } catch (parseError) {
59
+ toRemove.push(key);
60
+ }
61
+ }
62
+
63
+ toRemove.forEach((key) => window.localStorage.removeItem(key));
64
+ } catch (error) {
65
+ console.warn('Unable to sweep stale progress keys from local storage.', error);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Read a stored progress entry. Returns the unwrapped inner `value` only
71
+ * when the entry is present, well-formed, and within TTL; otherwise returns
72
+ * `null` and removes the bad key as a side effect.
73
+ *
74
+ * Callers must additionally verify `value.guestRef === currentGuestRef`
75
+ * before applying the result — the helper does not know the current guest.
76
+ */
77
+ export function readProgressEntry(key) {
78
+ if (!hasLocalStorage()) {
79
+ return null;
80
+ }
81
+
82
+ try {
83
+ const raw = window.localStorage.getItem(key);
84
+ if (!raw) {
85
+ return null;
86
+ }
87
+ const parsed = JSON.parse(raw);
88
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.savedAt !== 'number') {
89
+ window.localStorage.removeItem(key);
90
+ return null;
91
+ }
92
+ if (Date.now() - parsed.savedAt > PROGRESS_STORAGE_TTL_MS) {
93
+ window.localStorage.removeItem(key);
94
+ return null;
95
+ }
96
+ return parsed.value;
97
+ } catch (error) {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Write a stored progress entry with the current timestamp. On
104
+ * `QuotaExceededError`, sweep stale keys once and retry — that keeps the
105
+ * write path resilient on long-running kiosks without surfacing the error
106
+ * to the user.
107
+ */
108
+ export function writeProgressEntry(key, value) {
109
+ if (!hasLocalStorage()) {
110
+ return;
111
+ }
112
+
113
+ const payload = JSON.stringify({ savedAt: Date.now(), value });
114
+
115
+ try {
116
+ window.localStorage.setItem(key, payload);
117
+ } catch (error) {
118
+ sweepStaleProgressKeys();
119
+ try {
120
+ window.localStorage.setItem(key, payload);
121
+ } catch (retryError) {
122
+ console.warn('Unable to persist progress to local storage.', retryError);
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Remove a progress entry. Safe to call when the entry does not exist.
129
+ */
130
+ export function clearProgressEntry(key) {
131
+ if (!hasLocalStorage()) {
132
+ return;
133
+ }
134
+
135
+ try {
136
+ window.localStorage.removeItem(key);
137
+ } catch (error) {
138
+ console.warn('Unable to clear progress entry from local storage.', error);
139
+ }
140
+ }
@@ -18,7 +18,17 @@ import { ExternalWindow } from '../../components';
18
18
  import { Dashboard } from '../../models';
19
19
  import * as genericComponents from './../../lib';
20
20
  import { Button, Card, Location } from './../../lib';
21
+ import {
22
+ base64AudioToBlob,
23
+ buildGuestStepGuide,
24
+ extractGeminiAudio,
25
+ getElevenLabsApiKey,
26
+ getGeminiApiKey,
27
+ getOpenAIApiKey,
28
+ getSarvamApiKey,
29
+ } from './narration';
21
30
  import { createOpenAIRealtimeSession, hasOpenAIRealtimeCredentials } from './openai-realtime';
31
+ import { clearProgressEntry, readProgressEntry, sweepStaleProgressKeys, writeProgressEntry } from './progress-storage';
22
32
  import './steps.scss';
23
33
 
24
34
  const TOUCH_NAV_HIDE_DELAY = 2800;
@@ -36,39 +46,6 @@ const FIRST_STEP_LABELS = Object.freeze({
36
46
  consultation: 'Start Consultation',
37
47
  });
38
48
 
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
49
  const VOICE_PROVIDER_OPTIONS = [
73
50
  { label: 'Gemini', value: 'gemini' },
74
51
  { label: 'ElevenLabs', value: 'elevenlabs' },
@@ -133,157 +110,6 @@ const SARVAM_TARGET_LANGUAGE_CODE = process.env.SARVAM_TARGET_LANGUAGE_CODE || p
133
110
  const SARVAM_OUTPUT_AUDIO_CODEC = process.env.SARVAM_OUTPUT_AUDIO_CODEC || process.env.REACT_APP_SARVAM_OUTPUT_AUDIO_CODEC || 'wav';
134
111
  const NARRATION_CONTROLS_ENABLED = false;
135
112
 
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
- }
286
-
287
113
  export default function ProcessStepsPage({ match, CustomComponents = {}, ...props }) {
288
114
  const allComponents = { ...genericComponents, ...CustomComponents };
289
115
  const GuestInfoComponent = allComponents.EntryInfo;
@@ -292,9 +118,11 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
292
118
  const [steps, setSteps] = useState([]);
293
119
  const [processName, setProcessName] = useState(null);
294
120
  const [activeStep, setActiveStep] = useState(0);
121
+ const [resumableStep, setResumableStep] = useState(null);
295
122
  const [isStepCompleted, setIsStepCompleted] = useState(false);
296
123
 
297
124
  const [nextProcessId, setNextProcessId] = useState(null);
125
+ const [previousProcessId, setPreviousProcessId] = useState(null);
298
126
  const [stepStartTime, setStepStartTime] = useState(null);
299
127
  const [processStartTime, setProcessStartTime] = useState(null);
300
128
  const [processTimings, setProcessTimings] = useState([]);
@@ -334,29 +162,74 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
334
162
  const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
335
163
  let processId = urlParams.processId;
336
164
  const [currentProcessId, setCurrentProcessId] = useState(processId);
165
+
166
+ /**
167
+ * Storage scope for per-guest progress.
168
+ * - The localStorage keys combine the process and the guest reference so
169
+ * guest A's resume marker cannot appear for guest B.
170
+ * - Sources tried in order: query params (`opb_id`, `reference_id`, `opno`,
171
+ * `reference_number`), then React Router `match.params`, then the final
172
+ * pathname segment. Only when none produce a non-empty value does the
173
+ * scope collapse to `anonymous` — which is the symptom of "stores step
174
+ * but not per guest" because all guests then share one key.
175
+ */
176
+ const guestReference = (() => {
177
+ const fromQuery = urlParams?.opb_id || urlParams?.reference_id || urlParams?.opno || urlParams?.reference_number;
178
+ if (fromQuery) return String(fromQuery);
179
+
180
+ const params = match?.params || {};
181
+ const paramCandidates = [params.opb_id, params.reference_id, params.opno, params.reference_number, params.id];
182
+ const fromRoute = paramCandidates.find((value) => value != null && value !== '');
183
+ if (fromRoute) return String(fromRoute);
184
+
185
+ if (typeof window !== 'undefined' && window.location?.pathname) {
186
+ const segments = window.location.pathname.split('/').filter(Boolean);
187
+ const last = segments[segments.length - 1];
188
+ if (last) return String(last);
189
+ }
190
+
191
+ return 'anonymous';
192
+ })();
193
+ const timingsStorageKey = `processTimings_${currentProcessId}_${guestReference}`;
194
+ const activeStepStorageKey = `processActiveStep_${currentProcessId}_${guestReference}`;
195
+
337
196
  // Load process details based on the current process ID
338
197
  useEffect(() => {
339
- loadProcess(currentProcessId);
198
+ sweepStaleProgressKeys();
340
199
 
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
- }
200
+ loadProcess(currentProcessId);
353
201
 
202
+ /**
203
+ * Both the key AND the stored value are scoped to the guest. A read is
204
+ * only accepted when the value's `guestRef` matches the current guest;
205
+ * any mismatch (or a legacy unwrapped value from before this change) is
206
+ * treated as "no saved progress" so guest A's data can never appear for
207
+ * guest B even if a key collision somehow occurred.
208
+ */
209
+ const savedTimingsEntry = readProgressEntry(timingsStorageKey);
210
+ const savedTimings =
211
+ savedTimingsEntry && savedTimingsEntry.guestRef === guestReference && Array.isArray(savedTimingsEntry.timings) ? savedTimingsEntry.timings : [];
354
212
  setProcessTimings(savedTimings);
355
213
 
214
+ const savedStepEntry = readProgressEntry(activeStepStorageKey);
215
+ const parsedStep = savedStepEntry && savedStepEntry.guestRef === guestReference ? Number(savedStepEntry.step) : NaN;
216
+ const savedActiveStep = Number.isFinite(parsedStep) && parsedStep > 0 ? parsedStep : 0;
217
+ setResumableStep(savedActiveStep > 0 ? savedActiveStep : null);
218
+
356
219
  setProcessStartTime(Date.now());
357
220
  setStepStartTime(Date.now());
358
221
  setShowNextProcessAction(false);
359
- }, [currentProcessId]);
222
+ setActiveStep(0);
223
+ }, [currentProcessId, guestReference]);
224
+
225
+ /**
226
+ * The active step is persisted inline in `gotoStep` rather than via a
227
+ * useEffect — running it as an effect captured `activeStepStorageKey` from
228
+ * a closure that could go stale during URL changes, allowing one guest's
229
+ * progress to be written under another guest's key. Writing in `gotoStep`
230
+ * happens synchronously with the user action using the current render's
231
+ * key, so it can never target a different guest than the one interacting.
232
+ */
360
233
 
361
234
  /**
362
235
  * Sync the loaded process name into the address bar.
@@ -373,24 +246,45 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
373
246
  }
374
247
 
375
248
  const params = new URLSearchParams(window.location.search);
376
- const trimmedName = typeof processName === 'string' ? processName.trim() : '';
249
+ let changed = false;
377
250
 
251
+ const trimmedName = typeof processName === 'string' ? processName.trim() : '';
378
252
  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;
253
+ if (params.get('process') !== trimmedName) {
254
+ params.set('process', trimmedName);
255
+ changed = true;
386
256
  }
257
+ } else if (params.has('process')) {
387
258
  params.delete('process');
259
+ changed = true;
260
+ }
261
+
262
+ /**
263
+ * Mirror `currentProcessId` to the `processId` query param so previous /
264
+ * next process navigation reflects in the URL (e.g. when jumping from
265
+ * Verification → Consultation, the address bar should switch from
266
+ * `processId=1` to `processId=2`). Without this, deep-links and refreshes
267
+ * would still resolve to the originally loaded process.
268
+ */
269
+ const processIdString = currentProcessId != null ? String(currentProcessId) : '';
270
+ if (processIdString) {
271
+ if (params.get('processId') !== processIdString) {
272
+ params.set('processId', processIdString);
273
+ changed = true;
274
+ }
275
+ } else if (params.has('processId')) {
276
+ params.delete('processId');
277
+ changed = true;
278
+ }
279
+
280
+ if (!changed) {
281
+ return;
388
282
  }
389
283
 
390
284
  const search = params.toString();
391
285
  const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash || ''}`;
392
286
  window.history.replaceState(window.history.state, '', newUrl);
393
- }, [processName]);
287
+ }, [processName, currentProcessId]);
394
288
 
395
289
  //// Reset step start time whenever the active step changes
396
290
 
@@ -503,12 +397,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
503
397
  const saveTimings = (updated) => {
504
398
  const safeTimings = Array.isArray(updated) ? updated : [];
505
399
  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
- }
400
+ writeProgressEntry(timingsStorageKey, { guestRef: guestReference, timings: safeTimings });
512
401
  };
513
402
  // Record time spent on the current step
514
403
 
@@ -557,6 +446,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
557
446
  async function loadProcess(processId) {
558
447
  setLoading(true);
559
448
  setNextProcessId(null);
449
+ setPreviousProcessId(null);
560
450
 
561
451
  try {
562
452
  const result = await Dashboard.loadProcess(processId);
@@ -564,6 +454,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
564
454
  setSteps(result?.data?.steps || []);
565
455
  setProcessName(result?.data?.process_name ?? null);
566
456
  if (result?.data?.next_process_id) setNextProcessId(result.data);
457
+ if (result?.data?.previous_process_id) setPreviousProcessId(result.data);
567
458
  } catch (e) {
568
459
  console.error('Error loading process steps:', e);
569
460
  } finally {
@@ -597,12 +488,10 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
597
488
  const response = await Dashboard.processLog(payload);
598
489
 
599
490
  if (response.success) {
600
- try {
601
- localStorage.removeItem(`processTimings_${currentProcessId}`);
602
- } catch (error) {
603
- console.warn('Unable to clear process timings from local storage.', error);
604
- }
491
+ clearProgressEntry(timingsStorageKey);
492
+ clearProgressEntry(activeStepStorageKey);
605
493
  setProcessTimings([]);
494
+ setResumableStep(null);
606
495
  return true;
607
496
  }
608
497
  } catch (e) {
@@ -636,6 +525,19 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
636
525
  const updated = recordStepTime(status);
637
526
  saveTimings(updated);
638
527
  setActiveStep(nextIndex);
528
+
529
+ /**
530
+ * Persist the resume marker synchronously here, not in a useEffect. The
531
+ * current render's `activeStepStorageKey` is guaranteed to belong to the
532
+ * guest the user is interacting with, so the write cannot leak to a
533
+ * different guest's key. Step 0 is the entry point — clear any marker
534
+ * so a returning visitor at step 0 does not see a stale banner.
535
+ */
536
+ if (nextIndex > 0 && currentProcessId) {
537
+ writeProgressEntry(activeStepStorageKey, { guestRef: guestReference, step: nextIndex });
538
+ } else if (nextIndex === 0) {
539
+ clearProgressEntry(activeStepStorageKey);
540
+ }
639
541
  };
640
542
  /**
641
543
  * Navigate to the next step
@@ -659,6 +561,31 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
659
561
  * - Records timing data for the current step.
660
562
  */
661
563
  const handleTimelineClick = (i) => gotoStep(i);
564
+ /**
565
+ * Resume Handlers
566
+ * - `handleResume` jumps to the persisted step (clamped to current step
567
+ * range) so a returning user picks up where they left off.
568
+ * - `dismissResume` discards the saved marker so the banner does not appear
569
+ * again for this process.
570
+ */
571
+ const handleResume = () => {
572
+ if (resumableStep == null || !steps.length) {
573
+ return;
574
+ }
575
+ const target = Math.max(0, Math.min(resumableStep, steps.length - 1));
576
+ setResumableStep(null);
577
+ if (target !== activeStep) {
578
+ gotoStep(target);
579
+ }
580
+ };
581
+ const dismissResume = () => {
582
+ setResumableStep(null);
583
+ try {
584
+ clearProgressEntry(activeStepStorageKey);
585
+ } catch (error) {
586
+ console.warn('Unable to clear resume marker from local storage.', error);
587
+ }
588
+ };
662
589
  /**
663
590
  * Process Completion
664
591
  * - Records final step timing.
@@ -686,6 +613,23 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
686
613
  setShowExternalWindow(true);
687
614
  }
688
615
  };
616
+ /**
617
+ * Go Back to Previous Process
618
+ * - Loads the previously linked process for the current guest.
619
+ * - Does NOT submit the current process; this is a "go back" navigation,
620
+ * not a completion. Step timings collected so far stay in localStorage
621
+ * under the current process's scoped key in case the user returns.
622
+ * - Updates `currentProcessId` which triggers the load effect to refresh
623
+ * process data, reset `activeStep` to 0, and re-derive storage scope.
624
+ */
625
+ const handleStartPreviousProcess = async () => {
626
+ if (!previousProcessId?.previous_process_id) {
627
+ return;
628
+ }
629
+ await loadProcess(previousProcessId.previous_process_id);
630
+ setCurrentProcessId(previousProcessId.previous_process_id);
631
+ setActiveStep(0);
632
+ };
689
633
 
690
634
  function clearNarrationAudio() {
691
635
  if (narrationAudioRef.current) {
@@ -1375,6 +1319,19 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
1375
1319
  {isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1376
1320
  </Button>
1377
1321
 
1322
+ {/*
1323
+ Previous-process button.
1324
+ - Only relevant at the start of a process (`activeStep === 0`)
1325
+ AND when the backend signalled a `previous_process_id`. Mid-
1326
+ process the in-step Back button handles intra-process
1327
+ navigation, so showing this here would be ambiguous.
1328
+ */}
1329
+ {activeStep === 0 && previousProcessId?.previous_process_id && (
1330
+ <Button type="default" icon={<ArrowLeftOutlined />} onClick={handleStartPreviousProcess}>
1331
+ {previousProcessId.previous_process_name ? `Back to ${previousProcessId.previous_process_name}` : 'Previous Process'}
1332
+ </Button>
1333
+ )}
1334
+
1378
1335
  {activeStep > 0 && (
1379
1336
  <Button type="default" icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
1380
1337
  Back
@@ -1418,13 +1375,39 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
1418
1375
  generic "Next" label. All non-first steps always render
1419
1376
  "Next".
1420
1377
  */}
1421
- {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}{' '}
1422
- <ArrowRightOutlined />
1378
+ {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'} <ArrowRightOutlined />
1423
1379
  </Button>
1424
1380
  )}
1425
1381
  </div>
1426
1382
  </div>
1427
1383
 
1384
+ {/*
1385
+ Resume banner.
1386
+ - Renders only when a saved active step is ahead of the current
1387
+ position, so it stays out of the way during normal navigation
1388
+ and only surfaces when the user returns after an unexpected
1389
+ exit (refresh, tab close, navigation away).
1390
+ - Resuming clamps to the current step range; dismissing clears
1391
+ the persisted marker so the banner does not return for this
1392
+ process.
1393
+ */}
1394
+ {resumableStep != null && resumableStep > activeStep && resumableStep < steps.length ? (
1395
+ <div className="steps-resume-banner" role="status">
1396
+ <span className="steps-resume-banner-text">
1397
+ You left at Step {resumableStep + 1}
1398
+ {steps[resumableStep]?.step_name ? ` — ${steps[resumableStep].step_name}` : ''}.
1399
+ </span>
1400
+ <div className="steps-resume-banner-actions">
1401
+ <Button type="primary" size="small" onClick={handleResume}>
1402
+ Resume
1403
+ </Button>
1404
+ <Button type="text" size="small" onClick={dismissResume}>
1405
+ Dismiss
1406
+ </Button>
1407
+ </div>
1408
+ </div>
1409
+ ) : null}
1410
+
1428
1411
  <div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1429
1412
  {/*
1430
1413
  Stage body:
@@ -212,6 +212,37 @@
212
212
  height: 3px;
213
213
  }
214
214
 
215
+ /* ── Resume banner ──────────────────────────────────────── */
216
+
217
+ .steps-resume-banner {
218
+ flex: 0 0 auto;
219
+ display: flex;
220
+ align-items: center;
221
+ justify-content: space-between;
222
+ gap: 12px;
223
+ padding: 8px 14px;
224
+ background: #fff8e1;
225
+ border-bottom: 1px solid #f5e3a3;
226
+ color: #5b4400;
227
+ font-size: 13px;
228
+ font-weight: 500;
229
+ }
230
+
231
+ .steps-resume-banner-text {
232
+ flex: 1 1 auto;
233
+ min-width: 0;
234
+ overflow: hidden;
235
+ text-overflow: ellipsis;
236
+ white-space: nowrap;
237
+ }
238
+
239
+ .steps-resume-banner-actions {
240
+ flex: 0 0 auto;
241
+ display: flex;
242
+ align-items: center;
243
+ gap: 6px;
244
+ }
245
+
215
246
  .steps-breadcrumb-strip::-webkit-scrollbar-track {
216
247
  background: transparent;
217
248
  }
@@ -474,13 +505,17 @@
474
505
 
475
506
  @media (prefers-reduced-motion: reduce) {
476
507
  .steps-touch-nav {
477
- transition: opacity 120ms linear, visibility 0s linear 120ms;
508
+ transition:
509
+ opacity 120ms linear,
510
+ visibility 0s linear 120ms;
478
511
  transform: translateY(-50%);
479
512
  }
480
513
 
481
514
  .steps-touch-nav.is-visible {
482
515
  transform: translateY(-50%);
483
- transition: opacity 120ms linear, visibility 0s linear 0s;
516
+ transition:
517
+ opacity 120ms linear,
518
+ visibility 0s linear 0s;
484
519
  }
485
520
 
486
521
  .steps-touch-nav.is-visible:not(:disabled)::before {
@@ -635,7 +670,7 @@
635
670
  }
636
671
 
637
672
  .steps-chat-step-component {
638
- margin-top: 10px;
673
+ // margin-top: 10px;
639
674
  display: flex;
640
675
  flex: 1 1 auto;
641
676
  flex-direction: column;
@@ -730,14 +765,14 @@
730
765
 
731
766
  .steps-viewport:fullscreen .steps-stage-body,
732
767
  .steps-viewport:-webkit-full-screen .steps-stage-body {
733
- padding: 6px;
768
+ padding: 3px;
734
769
  }
735
770
 
736
771
  /* ── Small laptops (13" / 1366px and below) ─────────────── */
737
772
 
738
773
  @media (max-width: 1366px) {
739
774
  .steps-top-bar {
740
- padding: 6px 10px;
775
+ // padding: 6px 10px;
741
776
  gap: 6px;
742
777
  }
743
778
 
@@ -768,7 +803,7 @@
768
803
  }
769
804
 
770
805
  .steps-chat-step-card {
771
- padding: 12px 14px;
806
+ padding: 9px;
772
807
  }
773
808
 
774
809
  .steps-title {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-soxo-bootstrap-core",
3
- "version": "2.6.32-dev.5",
3
+ "version": "2.6.32-dev.7",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"