ui-soxo-bootstrap-core 2.6.37 → 2.6.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/lib/elements/basic/table/table.js +1 -6
- package/core/lib/elements/complex/qrscanner/qrscanner.js +1 -1
- package/core/modules/steps/narration.js +192 -0
- package/core/modules/steps/progress-storage.js +140 -0
- package/core/modules/steps/steps.js +203 -220
- package/core/modules/steps/steps.scss +41 -6
- package/package.json +1 -1
|
@@ -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}
|
|
@@ -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
|
-
|
|
198
|
+
sweepStaleProgressKeys();
|
|
340
199
|
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
let changed = false;
|
|
377
250
|
|
|
251
|
+
const trimmedName = typeof processName === 'string' ? processName.trim() : '';
|
|
378
252
|
if (trimmedName) {
|
|
379
|
-
if (params.get('process')
|
|
380
|
-
|
|
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
|
-
|
|
601
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
806
|
+
padding: 9px;
|
|
772
807
|
}
|
|
773
808
|
|
|
774
809
|
.steps-title {
|