ui-soxo-bootstrap-core 2.6.32-dev.6 → 2.6.32-dev.8
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/.github/workflows/npm-publish.yml +40 -59
- package/DEVELOPER_GUIDE.md +29 -19
- package/core/lib/components/global-header/global-header.js +4 -2
- package/core/modules/steps/narration.js +192 -0
- package/core/modules/steps/progress-storage.js +140 -0
- package/core/modules/steps/steps.js +145 -254
- package/package.json +1 -1
|
@@ -1,59 +1,40 @@
|
|
|
1
|
-
name: Node.js Package
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [created]
|
|
6
|
-
|
|
7
|
-
jobs:
|
|
8
|
-
publish-npm:
|
|
9
|
-
runs-on: ubuntu-latest
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
echo "
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
echo "tag
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- name: Diagnose npm + OIDC environment
|
|
43
|
-
shell: bash
|
|
44
|
-
run: |
|
|
45
|
-
echo "--- versions ---"
|
|
46
|
-
node --version
|
|
47
|
-
npm --version
|
|
48
|
-
echo "--- OIDC env presence (must both be 'yes' for trusted publishing) ---"
|
|
49
|
-
echo "ACTIONS_ID_TOKEN_REQUEST_URL set: ${ACTIONS_ID_TOKEN_REQUEST_URL:+yes}"
|
|
50
|
-
echo "ACTIONS_ID_TOKEN_REQUEST_TOKEN set: ${ACTIONS_ID_TOKEN_REQUEST_TOKEN:+yes}"
|
|
51
|
-
echo "--- effective .npmrc (user) ---"
|
|
52
|
-
cat ~/.npmrc 2>/dev/null || echo "(none)"
|
|
53
|
-
echo "--- effective .npmrc (project) ---"
|
|
54
|
-
cat .npmrc 2>/dev/null || echo "(none)"
|
|
55
|
-
echo "--- npm config (auth-related) ---"
|
|
56
|
-
npm config get registry
|
|
57
|
-
npm config get //registry.npmjs.org/:_authToken || true
|
|
58
|
-
|
|
59
|
-
- run: npm publish --provenance --access public --tag ${{ steps.dist_tag.outputs.tag }} --loglevel=verbose
|
|
1
|
+
name: Node.js Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish-npm:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-node@v4
|
|
13
|
+
with:
|
|
14
|
+
node-version: 20
|
|
15
|
+
registry-url: https://registry.npmjs.org/
|
|
16
|
+
- run: npm install
|
|
17
|
+
|
|
18
|
+
- name: Determine npm dist-tag
|
|
19
|
+
id: dist_tag
|
|
20
|
+
shell: bash
|
|
21
|
+
run: |
|
|
22
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
23
|
+
echo "package.json version: $VERSION"
|
|
24
|
+
echo "release tag: ${GITHUB_REF_NAME}"
|
|
25
|
+
if [[ "v${VERSION}" != "${GITHUB_REF_NAME}" ]]; then
|
|
26
|
+
echo "::error::Release tag '${GITHUB_REF_NAME}' does not match package.json version 'v${VERSION}'."
|
|
27
|
+
echo "::error::Bump the version with 'npm version' and re-create the release."
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
if [[ "$VERSION" == *-dev* ]]; then
|
|
31
|
+
echo "tag=dev" >> "$GITHUB_OUTPUT"
|
|
32
|
+
echo "Will publish with dist-tag: dev"
|
|
33
|
+
else
|
|
34
|
+
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
|
35
|
+
echo "Will publish with dist-tag: latest"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
- run: npm publish --access public --tag ${{ steps.dist_tag.outputs.tag }}
|
|
39
|
+
env:
|
|
40
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/DEVELOPER_GUIDE.md
CHANGED
|
@@ -17,7 +17,7 @@ Incorrect versioning or incorrect tags will break the publish pipeline — follo
|
|
|
17
17
|
- Publishing via GitHub Release UI
|
|
18
18
|
- How GitHub Action Detects Release Type
|
|
19
19
|
- Summary Table
|
|
20
|
-
- CI/CD Authentication (
|
|
20
|
+
- CI/CD Authentication (Granular Access Token)
|
|
21
21
|
- Common Mistakes & Fixes
|
|
22
22
|
|
|
23
23
|
---
|
|
@@ -258,10 +258,10 @@ npm publish --tag dev
|
|
|
258
258
|
|
|
259
259
|
The workflow reads the `version` field from `package.json` at publish time:
|
|
260
260
|
|
|
261
|
-
| Condition | Command
|
|
262
|
-
| ---------------------------- |
|
|
263
|
-
| Version contains `-dev` | `npm publish --
|
|
264
|
-
| Version has no `-dev` suffix | `npm publish --
|
|
261
|
+
| Condition | Command | Result |
|
|
262
|
+
| ---------------------------- | ------------------------------------------ | ---------------------------------- |
|
|
263
|
+
| Version contains `-dev` | `npm publish --access public --tag dev` | Publishes to the `dev` dist-tag |
|
|
264
|
+
| Version has no `-dev` suffix | `npm publish --access public --tag latest` | Publishes to the `latest` dist-tag |
|
|
265
265
|
|
|
266
266
|
The workflow also enforces that the GitHub release tag matches `v<version>` from `package.json` and fails the run immediately if they diverge — this prevents the most common publish failure described below.
|
|
267
267
|
|
|
@@ -281,9 +281,9 @@ The workflow also enforces that the GitHub release tag matches `v<version>` from
|
|
|
281
281
|
|
|
282
282
|
---
|
|
283
283
|
|
|
284
|
-
# 🔐 CI/CD Authentication (
|
|
284
|
+
# 🔐 CI/CD Authentication (Granular Access Token)
|
|
285
285
|
|
|
286
|
-
As of npm's 2025 policy changes, classic automation tokens (`
|
|
286
|
+
As of npm's 2025 policy changes, **classic automation tokens** (`npm_xxx` legacy tokens) are deprecated. This repo authenticates to npm using a **Granular Access Token (GAT)** — npm's current recommended token type for CI/CD. The token is stored as a GitHub repository secret named `NPM_TOKEN`.
|
|
287
287
|
|
|
288
288
|
## What this means for developers
|
|
289
289
|
|
|
@@ -291,24 +291,34 @@ Nothing. You still follow the same flow: `npm version` → push tag → create G
|
|
|
291
291
|
|
|
292
292
|
## What this means for maintainers
|
|
293
293
|
|
|
294
|
-
The first-time setup
|
|
294
|
+
The first-time setup must be done once per package:
|
|
295
295
|
|
|
296
|
-
1. Log in to [npmjs.com](https://www.npmjs.com)
|
|
297
|
-
2.
|
|
298
|
-
|
|
299
|
-
-
|
|
300
|
-
-
|
|
301
|
-
-
|
|
302
|
-
-
|
|
303
|
-
|
|
296
|
+
1. Log in to [npmjs.com](https://www.npmjs.com) as a user with publish rights to `ui-soxo-bootstrap-core`.
|
|
297
|
+
2. Top-right avatar → **Access Tokens** → **Generate New Token** → **Granular Access Token**.
|
|
298
|
+
3. Configure the token:
|
|
299
|
+
- **Name**: `ui-soxo-bootstrap-core CI publish`
|
|
300
|
+
- **Expiration**: 1 year (set a calendar reminder to rotate)
|
|
301
|
+
- **Packages and scopes**: Select **Only select packages and scopes** → choose `ui-soxo-bootstrap-core` → permission **Read and write**
|
|
302
|
+
- **IP allowlist**: leave blank (GitHub Actions runner IPs rotate)
|
|
303
|
+
4. Generate and **copy the token immediately** — npm only shows it once.
|
|
304
|
+
5. In GitHub: repo **Settings** → **Secrets and variables** → **Actions** → **New repository secret**:
|
|
305
|
+
- Name: `NPM_TOKEN`
|
|
306
|
+
- Secret: paste the token from step 4
|
|
307
|
+
|
|
308
|
+
When the token expires, repeat steps 2–5 and replace the `NPM_TOKEN` secret.
|
|
304
309
|
|
|
305
310
|
## Runtime requirements
|
|
306
311
|
|
|
307
|
-
The workflow runs on Node 20
|
|
312
|
+
The workflow runs on Node 20. The token is passed to `npm publish` via the `NODE_AUTH_TOKEN` environment variable, which `actions/setup-node` wires into `~/.npmrc` automatically when `registry-url` is set.
|
|
308
313
|
|
|
309
|
-
## If publish fails
|
|
314
|
+
## If publish fails
|
|
310
315
|
|
|
311
|
-
|
|
316
|
+
| Symptom | Likely cause | Fix |
|
|
317
|
+
| --- | --- | --- |
|
|
318
|
+
| `404 Not Found - PUT https://registry.npmjs.org/...` with no auth-related notices | `NPM_TOKEN` secret is missing, expired, or revoked | Re-create the GAT (steps 2–5) and re-publish |
|
|
319
|
+
| `403 Forbidden` | The GAT exists but doesn't have write access to this package | Recreate the token with **Read and write** on `ui-soxo-bootstrap-core` |
|
|
320
|
+
| `EOTP` / `ENEEDOTP` | The npm user enforces 2FA on writes and the token isn't allowed to bypass it | Recreate as a GAT (GATs bypass 2FA for their selected packages by design) |
|
|
321
|
+
| `Tag does not match package.json version` (workflow error) | Release tag and `package.json` version diverge | Always bump with `npm version` — never tag manually |
|
|
312
322
|
|
|
313
323
|
---
|
|
314
324
|
|
|
@@ -79,6 +79,7 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
|
|
|
79
79
|
}, []);
|
|
80
80
|
useEffect(() => {}, [state.theme]);
|
|
81
81
|
return (
|
|
82
|
+
<>
|
|
82
83
|
<div
|
|
83
84
|
className={`global-header ${process.env.REACT_APP_THEME} ${isConnected && !kiosk ? 'connected' : ''}`}
|
|
84
85
|
style={{
|
|
@@ -236,7 +237,8 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
|
|
|
236
237
|
</div>
|
|
237
238
|
{/* Right Section of the Component Loader Ends */}
|
|
238
239
|
</div>
|
|
239
|
-
|
|
240
|
+
</div>
|
|
241
|
+
{licAlert && licenseData && (
|
|
240
242
|
<div
|
|
241
243
|
style={{
|
|
242
244
|
top: 0,
|
|
@@ -249,7 +251,7 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
|
|
|
249
251
|
<LicenseAlert data={licenseData} />
|
|
250
252
|
</div>
|
|
251
253
|
)}
|
|
252
|
-
|
|
254
|
+
</>
|
|
253
255
|
);
|
|
254
256
|
}
|
|
255
257
|
export default function GlobalHeader(props) {
|
|
@@ -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;
|
|
@@ -296,6 +122,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
296
122
|
const [isStepCompleted, setIsStepCompleted] = useState(false);
|
|
297
123
|
|
|
298
124
|
const [nextProcessId, setNextProcessId] = useState(null);
|
|
125
|
+
const [previousProcessId, setPreviousProcessId] = useState(null);
|
|
299
126
|
const [stepStartTime, setStepStartTime] = useState(null);
|
|
300
127
|
const [processStartTime, setProcessStartTime] = useState(null);
|
|
301
128
|
const [processTimings, setProcessTimings] = useState([]);
|
|
@@ -335,64 +162,74 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
335
162
|
const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
|
|
336
163
|
let processId = urlParams.processId;
|
|
337
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
|
+
|
|
338
196
|
// Load process details based on the current process ID
|
|
339
197
|
useEffect(() => {
|
|
340
|
-
|
|
198
|
+
sweepStaleProgressKeys();
|
|
341
199
|
|
|
342
|
-
|
|
343
|
-
try {
|
|
344
|
-
const saved = localStorage.getItem(`processTimings_${currentProcessId}`);
|
|
345
|
-
if (saved) {
|
|
346
|
-
const parsed = JSON.parse(saved);
|
|
347
|
-
if (Array.isArray(parsed)) {
|
|
348
|
-
savedTimings = parsed;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
} catch (error) {
|
|
352
|
-
console.warn('Unable to restore process timings from local storage.', error);
|
|
353
|
-
}
|
|
200
|
+
loadProcess(currentProcessId);
|
|
354
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 : [];
|
|
355
212
|
setProcessTimings(savedTimings);
|
|
356
213
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const parsedStep = rawStep == null ? NaN : parseInt(rawStep, 10);
|
|
361
|
-
if (Number.isFinite(parsedStep) && parsedStep > 0) {
|
|
362
|
-
savedActiveStep = parsedStep;
|
|
363
|
-
}
|
|
364
|
-
} catch (error) {
|
|
365
|
-
console.warn('Unable to restore active step from local storage.', error);
|
|
366
|
-
}
|
|
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;
|
|
367
217
|
setResumableStep(savedActiveStep > 0 ? savedActiveStep : null);
|
|
368
218
|
|
|
369
219
|
setProcessStartTime(Date.now());
|
|
370
220
|
setStepStartTime(Date.now());
|
|
371
221
|
setShowNextProcessAction(false);
|
|
372
|
-
|
|
222
|
+
setActiveStep(0);
|
|
223
|
+
}, [currentProcessId, guestReference]);
|
|
373
224
|
|
|
374
225
|
/**
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
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.
|
|
380
232
|
*/
|
|
381
|
-
useEffect(() => {
|
|
382
|
-
if (typeof window === 'undefined' || !window.localStorage || !currentProcessId) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (activeStep <= 0) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
try {
|
|
391
|
-
localStorage.setItem(`processActiveStep_${currentProcessId}`, String(activeStep));
|
|
392
|
-
} catch (error) {
|
|
393
|
-
console.warn('Unable to persist active step to local storage.', error);
|
|
394
|
-
}
|
|
395
|
-
}, [activeStep, currentProcessId]);
|
|
396
233
|
|
|
397
234
|
/**
|
|
398
235
|
* Sync the loaded process name into the address bar.
|
|
@@ -409,24 +246,45 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
409
246
|
}
|
|
410
247
|
|
|
411
248
|
const params = new URLSearchParams(window.location.search);
|
|
412
|
-
|
|
249
|
+
let changed = false;
|
|
413
250
|
|
|
251
|
+
const trimmedName = typeof processName === 'string' ? processName.trim() : '';
|
|
414
252
|
if (trimmedName) {
|
|
415
|
-
if (params.get('process')
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
params.set('process', trimmedName);
|
|
419
|
-
} else {
|
|
420
|
-
if (!params.has('process')) {
|
|
421
|
-
return;
|
|
253
|
+
if (params.get('process') !== trimmedName) {
|
|
254
|
+
params.set('process', trimmedName);
|
|
255
|
+
changed = true;
|
|
422
256
|
}
|
|
257
|
+
} else if (params.has('process')) {
|
|
423
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;
|
|
424
282
|
}
|
|
425
283
|
|
|
426
284
|
const search = params.toString();
|
|
427
285
|
const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash || ''}`;
|
|
428
286
|
window.history.replaceState(window.history.state, '', newUrl);
|
|
429
|
-
}, [processName]);
|
|
287
|
+
}, [processName, currentProcessId]);
|
|
430
288
|
|
|
431
289
|
//// Reset step start time whenever the active step changes
|
|
432
290
|
|
|
@@ -539,12 +397,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
539
397
|
const saveTimings = (updated) => {
|
|
540
398
|
const safeTimings = Array.isArray(updated) ? updated : [];
|
|
541
399
|
setProcessTimings(safeTimings);
|
|
542
|
-
|
|
543
|
-
try {
|
|
544
|
-
localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(safeTimings));
|
|
545
|
-
} catch (error) {
|
|
546
|
-
console.warn('Unable to persist process timings to local storage.', error);
|
|
547
|
-
}
|
|
400
|
+
writeProgressEntry(timingsStorageKey, { guestRef: guestReference, timings: safeTimings });
|
|
548
401
|
};
|
|
549
402
|
// Record time spent on the current step
|
|
550
403
|
|
|
@@ -593,6 +446,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
593
446
|
async function loadProcess(processId) {
|
|
594
447
|
setLoading(true);
|
|
595
448
|
setNextProcessId(null);
|
|
449
|
+
setPreviousProcessId(null);
|
|
596
450
|
|
|
597
451
|
try {
|
|
598
452
|
const result = await Dashboard.loadProcess(processId);
|
|
@@ -600,6 +454,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
600
454
|
setSteps(result?.data?.steps || []);
|
|
601
455
|
setProcessName(result?.data?.process_name ?? null);
|
|
602
456
|
if (result?.data?.next_process_id) setNextProcessId(result.data);
|
|
457
|
+
if (result?.data?.previous_process_id) setPreviousProcessId(result.data);
|
|
603
458
|
} catch (e) {
|
|
604
459
|
console.error('Error loading process steps:', e);
|
|
605
460
|
} finally {
|
|
@@ -633,12 +488,8 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
633
488
|
const response = await Dashboard.processLog(payload);
|
|
634
489
|
|
|
635
490
|
if (response.success) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
localStorage.removeItem(`processActiveStep_${currentProcessId}`);
|
|
639
|
-
} catch (error) {
|
|
640
|
-
console.warn('Unable to clear process timings from local storage.', error);
|
|
641
|
-
}
|
|
491
|
+
clearProgressEntry(timingsStorageKey);
|
|
492
|
+
clearProgressEntry(activeStepStorageKey);
|
|
642
493
|
setProcessTimings([]);
|
|
643
494
|
setResumableStep(null);
|
|
644
495
|
return true;
|
|
@@ -674,6 +525,19 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
674
525
|
const updated = recordStepTime(status);
|
|
675
526
|
saveTimings(updated);
|
|
676
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
|
+
}
|
|
677
541
|
};
|
|
678
542
|
/**
|
|
679
543
|
* Navigate to the next step
|
|
@@ -717,9 +581,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
717
581
|
const dismissResume = () => {
|
|
718
582
|
setResumableStep(null);
|
|
719
583
|
try {
|
|
720
|
-
|
|
721
|
-
localStorage.removeItem(`processActiveStep_${currentProcessId}`);
|
|
722
|
-
}
|
|
584
|
+
clearProgressEntry(activeStepStorageKey);
|
|
723
585
|
} catch (error) {
|
|
724
586
|
console.warn('Unable to clear resume marker from local storage.', error);
|
|
725
587
|
}
|
|
@@ -751,6 +613,23 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
751
613
|
setShowExternalWindow(true);
|
|
752
614
|
}
|
|
753
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
|
+
};
|
|
754
633
|
|
|
755
634
|
function clearNarrationAudio() {
|
|
756
635
|
if (narrationAudioRef.current) {
|
|
@@ -1440,6 +1319,19 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
1440
1319
|
{isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
|
|
1441
1320
|
</Button>
|
|
1442
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
|
+
|
|
1443
1335
|
{activeStep > 0 && (
|
|
1444
1336
|
<Button type="default" icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
|
|
1445
1337
|
Back
|
|
@@ -1483,8 +1375,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
|
|
|
1483
1375
|
generic "Next" label. All non-first steps always render
|
|
1484
1376
|
"Next".
|
|
1485
1377
|
*/}
|
|
1486
|
-
{activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}
|
|
1487
|
-
<ArrowRightOutlined />
|
|
1378
|
+
{activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'} <ArrowRightOutlined />
|
|
1488
1379
|
</Button>
|
|
1489
1380
|
)}
|
|
1490
1381
|
</div>
|