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.
@@ -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
- permissions:
11
- contents: read
12
- id-token: write
13
- steps:
14
- - uses: actions/checkout@v4
15
- - uses: actions/setup-node@v4
16
- with:
17
- node-version: 20
18
- registry-url: https://registry.npmjs.org/
19
- - run: npm install -g npm@latest
20
- - run: npm install
21
-
22
- - name: Determine npm dist-tag
23
- id: dist_tag
24
- shell: bash
25
- run: |
26
- VERSION=$(node -p "require('./package.json').version")
27
- echo "package.json version: $VERSION"
28
- echo "release tag: ${GITHUB_REF_NAME}"
29
- if [[ "v${VERSION}" != "${GITHUB_REF_NAME}" ]]; then
30
- echo "::error::Release tag '${GITHUB_REF_NAME}' does not match package.json version 'v${VERSION}'."
31
- echo "::error::Bump the version with 'npm version' and re-create the release."
32
- exit 1
33
- fi
34
- if [[ "$VERSION" == *-dev* ]]; then
35
- echo "tag=dev" >> "$GITHUB_OUTPUT"
36
- echo "Will publish with dist-tag: dev"
37
- else
38
- echo "tag=latest" >> "$GITHUB_OUTPUT"
39
- echo "Will publish with dist-tag: latest"
40
- fi
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 }}
@@ -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 (Trusted Publishing)
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 | Result |
262
- | ---------------------------- | ------------------------------------------------------- | ---------------------------------- |
263
- | Version contains `-dev` | `npm publish --provenance --access public --tag dev` | Publishes to the `dev` dist-tag |
264
- | Version has no `-dev` suffix | `npm publish --provenance --access public --tag latest` | Publishes to the `latest` dist-tag |
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 (Trusted Publishing)
284
+ # 🔐 CI/CD Authentication (Granular Access Token)
285
285
 
286
- As of npm's 2025 policy changes, classic automation tokens (`NPM_TOKEN`) are deprecated. This repo now authenticates to npm via **OIDC Trusted Publishing** — GitHub Actions exchanges a short-lived OIDC token for a publish token at run time, so **no secret is stored in the repository**.
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 on npmjs.com must be done once per package:
294
+ The first-time setup must be done once per package:
295
295
 
296
- 1. Log in to [npmjs.com](https://www.npmjs.com) open the package (`ui-soxo-bootstrap-core`) → **Settings**.
297
- 2. Under **Trusted Publisher**, click **Add trusted publisher** and fill in:
298
- - Publisher: **GitHub Actions**
299
- - Organization or user: `soxo-tech`
300
- - Repository: `bootstrap-core`
301
- - Workflow filename: `npm-publish.yml`
302
- - Environment name: *(leave blank)*
303
- 3. Save. Any old `NPM_TOKEN` repository secret can be removed.
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 and upgrades npm to the latest CLI (`npm install -g npm@latest`) because OIDC trusted publishing requires **npm 11.5.1**. The `--provenance` flag attaches a verifiable build attestation to every published version, visible on the npmjs.com package page.
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 with `403 Forbidden` or `ENEEDAUTH`
314
+ ## If publish fails
310
315
 
311
- The trusted publisher config on npmjs.com no longer matches the workflow. Check that org, repo, and workflow filename match exactly — including case.
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
- {licAlert && licenseData && (
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
- </div>
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
- loadProcess(currentProcessId);
198
+ sweepStaleProgressKeys();
341
199
 
342
- let savedTimings = [];
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
- let savedActiveStep = 0;
358
- try {
359
- const rawStep = localStorage.getItem(`processActiveStep_${currentProcessId}`);
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
- }, [currentProcessId]);
222
+ setActiveStep(0);
223
+ }, [currentProcessId, guestReference]);
373
224
 
374
225
  /**
375
- * Persist the active step so an unexpected exit (refresh, tab close, crash)
376
- * can offer a Resume action on the next mount. Only steps beyond the first
377
- * are persisted step 0 is the natural entry point and does not need a
378
- * resume marker. The marker is cleared on successful process submission and
379
- * when the user explicitly dismisses the resume banner.
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
- const trimmedName = typeof processName === 'string' ? processName.trim() : '';
249
+ let changed = false;
413
250
 
251
+ const trimmedName = typeof processName === 'string' ? processName.trim() : '';
414
252
  if (trimmedName) {
415
- if (params.get('process') === trimmedName) {
416
- return;
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
- try {
637
- localStorage.removeItem(`processTimings_${currentProcessId}`);
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
- if (typeof window !== 'undefined' && window.localStorage && currentProcessId) {
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-soxo-bootstrap-core",
3
- "version": "2.6.32-dev.6",
3
+ "version": "2.6.32-dev.8",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"