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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@
5
5
  * - Dynamically renders step-specific components based on configuration.
6
6
  * - Tracks step and process durations with local persistence support.
7
7
  * - Supports step navigation (next, previous, skip, breadcrumb, keyboard).
8
+ * - Touchscreen support: horizontal swipe gestures navigate between steps
9
+ * and transient left/right arrow buttons fade in on touch for discovery.
8
10
  * - Handles process submission and optional chaining to the next process.
9
11
  * - Renders a single active step view with compact breadcrumb controls.
10
12
  */
@@ -19,6 +21,21 @@ import { Button, Card, Location } from './../../lib';
19
21
  import { createOpenAIRealtimeSession, hasOpenAIRealtimeCredentials } from './openai-realtime';
20
22
  import './steps.scss';
21
23
 
24
+ const TOUCH_NAV_HIDE_DELAY = 2800;
25
+ const SWIPE_DISTANCE_THRESHOLD = 60;
26
+ const SWIPE_VERTICAL_TOLERANCE = 80;
27
+
28
+ /**
29
+ * First-step CTA labels keyed by normalized process name.
30
+ * - Keys are the lowercased/trimmed process name returned by the backend.
31
+ * - Missing keys fall back to the generic 'Next' label at the call site.
32
+ * - Frozen so accidental mutation during render doesn't leak across renders.
33
+ */
34
+ const FIRST_STEP_LABELS = Object.freeze({
35
+ verification: 'Verify Profile',
36
+ consultation: 'Start Consultation',
37
+ });
38
+
22
39
  const STEP_WELCOME_LINES = [
23
40
  'Welcome to your AI Automated Consultation process.',
24
41
  'You are in the right place for a smooth and guided health journey.',
@@ -273,6 +290,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
273
290
 
274
291
  const [loading, setLoading] = useState(false);
275
292
  const [steps, setSteps] = useState([]);
293
+ const [processName, setProcessName] = useState(null);
276
294
  const [activeStep, setActiveStep] = useState(0);
277
295
  const [isStepCompleted, setIsStepCompleted] = useState(false);
278
296
 
@@ -300,6 +318,8 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
300
318
  const [showNextProcessAction, setShowNextProcessAction] = useState(false);
301
319
  const [isStepFullscreen, setIsStepFullscreen] = useState(false);
302
320
  const [realtimeStatus, setRealtimeStatus] = useState('idle');
321
+ const [isTouchDevice, setIsTouchDevice] = useState(false);
322
+ const [touchNavVisible, setTouchNavVisible] = useState(false);
303
323
 
304
324
  const narrationUtteranceRef = useRef(null);
305
325
  const narrationAudioRef = useRef(null);
@@ -307,6 +327,8 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
307
327
  const narrationFallbackNoticeRef = useRef(false);
308
328
  const realtimeSessionRef = useRef(null);
309
329
  const fullscreenViewportRef = useRef(null);
330
+ const touchStartRef = useRef(null);
331
+ const touchNavHideTimeoutRef = useRef(null);
310
332
 
311
333
  const urlParams = Location.search();
312
334
  const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
@@ -336,6 +358,40 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
336
358
  setShowNextProcessAction(false);
337
359
  }, [currentProcessId]);
338
360
 
361
+ /**
362
+ * Sync the loaded process name into the address bar.
363
+ * - Mirrors `processName` into a `process` query parameter so deep-links and
364
+ * refreshes carry the human-readable process label.
365
+ * - Uses `window.history.replaceState` to avoid a navigation event, which
366
+ * keeps React Router state and component instances stable.
367
+ * - Removes the param when `processName` is null/empty so stale values do
368
+ * not linger after a process clears.
369
+ */
370
+ useEffect(() => {
371
+ if (typeof window === 'undefined') {
372
+ return;
373
+ }
374
+
375
+ const params = new URLSearchParams(window.location.search);
376
+ const trimmedName = typeof processName === 'string' ? processName.trim() : '';
377
+
378
+ if (trimmedName) {
379
+ if (params.get('process') === trimmedName) {
380
+ return;
381
+ }
382
+ params.set('process', trimmedName);
383
+ } else {
384
+ if (!params.has('process')) {
385
+ return;
386
+ }
387
+ params.delete('process');
388
+ }
389
+
390
+ const search = params.toString();
391
+ const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash || ''}`;
392
+ window.history.replaceState(window.history.state, '', newUrl);
393
+ }, [processName]);
394
+
339
395
  //// Reset step start time whenever the active step changes
340
396
 
341
397
  useEffect(() => {
@@ -506,6 +562,7 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
506
562
  const result = await Dashboard.loadProcess(processId);
507
563
 
508
564
  setSteps(result?.data?.steps || []);
565
+ setProcessName(result?.data?.process_name ?? null);
509
566
  if (result?.data?.next_process_id) setNextProcessId(result.data);
510
567
  } catch (e) {
511
568
  console.error('Error loading process steps:', e);
@@ -1065,6 +1122,128 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
1065
1122
  };
1066
1123
  }, [activeStep, steps, externalWin]);
1067
1124
 
1125
+ /**
1126
+ * Touch-device detection.
1127
+ * - Runs once on mount.
1128
+ * - Uses `matchMedia('(pointer: coarse)')` as the primary signal because it
1129
+ * targets the actual input hardware (covers touch laptops correctly) and
1130
+ * falls back to `ontouchstart` / `navigator.maxTouchPoints` for older
1131
+ * browsers.
1132
+ * - When neither signal matches, the effect bails out and `isTouchDevice`
1133
+ * stays false so desktop renders without any touch-only UI.
1134
+ */
1135
+ useEffect(() => {
1136
+ if (typeof window === 'undefined') {
1137
+ return undefined;
1138
+ }
1139
+
1140
+ const hasCoarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
1141
+ const hasTouch = 'ontouchstart' in window || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
1142
+
1143
+ if (!hasCoarsePointer && !hasTouch) {
1144
+ return undefined;
1145
+ }
1146
+
1147
+ setIsTouchDevice(true);
1148
+ }, []);
1149
+
1150
+ /**
1151
+ * Show the floating prev/next arrow buttons and reset their auto-hide timer.
1152
+ * - Any pending hide timeout is cleared so a rapid sequence of touches keeps
1153
+ * the arrows on-screen continuously instead of flickering.
1154
+ * - A fresh timeout is scheduled for TOUCH_NAV_HIDE_DELAY so the arrows fade
1155
+ * away once the user stops interacting, keeping the step content clear.
1156
+ */
1157
+ const revealTouchNav = () => {
1158
+ if (typeof window === 'undefined') {
1159
+ return;
1160
+ }
1161
+
1162
+ setTouchNavVisible(true);
1163
+
1164
+ if (touchNavHideTimeoutRef.current) {
1165
+ window.clearTimeout(touchNavHideTimeoutRef.current);
1166
+ }
1167
+
1168
+ touchNavHideTimeoutRef.current = window.setTimeout(() => {
1169
+ setTouchNavVisible(false);
1170
+ touchNavHideTimeoutRef.current = null;
1171
+ }, TOUCH_NAV_HIDE_DELAY);
1172
+ };
1173
+
1174
+ /**
1175
+ * onTouchStart for the stage body.
1176
+ * - Records the initial touch position so handleStageTouchEnd can measure
1177
+ * the swipe delta.
1178
+ * - Also reveals the side arrows immediately, giving the user a visible
1179
+ * navigation affordance as soon as they touch the screen.
1180
+ */
1181
+ const handleStageTouchStart = (event) => {
1182
+ if (!isTouchDevice || !event.touches || !event.touches.length) {
1183
+ return;
1184
+ }
1185
+
1186
+ const touch = event.touches[0];
1187
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
1188
+ revealTouchNav();
1189
+ };
1190
+
1191
+ /**
1192
+ * onTouchEnd for the stage body.
1193
+ * - Computes the horizontal/vertical delta against the stored touch origin.
1194
+ * - Ignores gestures that are vertical-dominant or below the distance
1195
+ * threshold, so normal scrolling and short taps are not hijacked.
1196
+ * - A left swipe advances to the next step (subject to the same
1197
+ * `isStepCompleted` / final-step rules as the visible Next button); a
1198
+ * right swipe goes back. Each successful swipe re-reveals the arrows so
1199
+ * the user can continue tapping if they prefer.
1200
+ */
1201
+ const handleStageTouchEnd = (event) => {
1202
+ const start = touchStartRef.current;
1203
+ touchStartRef.current = null;
1204
+
1205
+ if (!start || !event.changedTouches || !event.changedTouches.length) {
1206
+ return;
1207
+ }
1208
+
1209
+ const touch = event.changedTouches[0];
1210
+ const deltaX = touch.clientX - start.x;
1211
+ const deltaY = touch.clientY - start.y;
1212
+
1213
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
1214
+ return;
1215
+ }
1216
+ if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD) {
1217
+ return;
1218
+ }
1219
+ if (Math.abs(deltaY) > SWIPE_VERTICAL_TOLERANCE) {
1220
+ return;
1221
+ }
1222
+
1223
+ if (deltaX < 0) {
1224
+ const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1225
+ if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1226
+ handleNext();
1227
+ revealTouchNav();
1228
+ }
1229
+ } else if (activeStep > 0) {
1230
+ handlePrevious();
1231
+ revealTouchNav();
1232
+ }
1233
+ };
1234
+
1235
+ /**
1236
+ * Cleanup any pending auto-hide timeout on unmount so the callback cannot
1237
+ * fire against a stale component and emit a React warning.
1238
+ */
1239
+ useEffect(() => {
1240
+ return () => {
1241
+ if (typeof window !== 'undefined' && touchNavHideTimeoutRef.current) {
1242
+ window.clearTimeout(touchNavHideTimeoutRef.current);
1243
+ }
1244
+ };
1245
+ }, []);
1246
+
1068
1247
  useEffect(() => {
1069
1248
  if (typeof document === 'undefined') {
1070
1249
  return undefined;
@@ -1180,12 +1359,12 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
1180
1359
  </div>
1181
1360
 
1182
1361
  <div className="steps-nav-actions">
1183
- <Button icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
1362
+ <Button type="dashed" icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
1184
1363
  {isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1185
1364
  </Button>
1186
1365
 
1187
1366
  {activeStep > 0 && (
1188
- <Button icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
1367
+ <Button type="default" icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
1189
1368
  Back
1190
1369
  </Button>
1191
1370
  )}
@@ -1219,14 +1398,77 @@ export default function ProcessStepsPage({ match, CustomComponents = {}, ...prop
1219
1398
  </>
1220
1399
  ) : (
1221
1400
  <Button type="primary" disabled={!isStepCompleted} onClick={handleNext}>
1222
- {activeStep === 0 ? 'Start Consultation' : 'Next'} <ArrowRightOutlined />
1401
+ {/*
1402
+ First-step label is resolved via FIRST_STEP_LABELS using
1403
+ the process name (lowercased + trimmed) as the key. Known
1404
+ processes get a tailored CTA (e.g. "Verify Profile",
1405
+ "Start Consultation"); unknown processes fall back to the
1406
+ generic "Next" label. All non-first steps always render
1407
+ "Next".
1408
+ */}
1409
+ {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}{' '}
1410
+ <ArrowRightOutlined />
1223
1411
  </Button>
1224
1412
  )}
1225
1413
  </div>
1226
1414
  </div>
1227
1415
 
1228
1416
  <div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1229
- <div className="steps-stage-body">
1417
+ {/*
1418
+ Stage body:
1419
+ - `is-swipe-enabled` applies `touch-action: pan-y` so horizontal
1420
+ gestures reach our handlers while vertical scrolling remains
1421
+ native.
1422
+ - Touch handlers are only attached on touch devices to keep
1423
+ desktop event trees untouched.
1424
+ */}
1425
+ <div
1426
+ className={`steps-stage-body${isTouchDevice ? ' is-swipe-enabled' : ''}`}
1427
+ onTouchStart={isTouchDevice ? handleStageTouchStart : undefined}
1428
+ onTouchEnd={isTouchDevice ? handleStageTouchEnd : undefined}
1429
+ >
1430
+ {/*
1431
+ Floating prev/next arrow buttons.
1432
+ - Rendered only on touch devices; `is-visible` class drives
1433
+ the fade-in/out via CSS transitions.
1434
+ - Disabled states mirror the visible Next/Back buttons in the
1435
+ top bar: previous disabled on the first step; next disabled
1436
+ on the last/final step or when the current step still
1437
+ requires user completion (isStepCompleted === false).
1438
+ - Clicking either button reveals the arrows again so the
1439
+ auto-hide timer restarts after every interaction.
1440
+ */}
1441
+ {isTouchDevice ? (
1442
+ <>
1443
+ <button
1444
+ type="button"
1445
+ className={`steps-touch-nav steps-touch-nav-left${touchNavVisible ? ' is-visible' : ''}`}
1446
+ aria-label="Previous step"
1447
+ disabled={activeStep === 0}
1448
+ onClick={() => {
1449
+ revealTouchNav();
1450
+ if (activeStep > 0) handlePrevious();
1451
+ }}
1452
+ >
1453
+ <ArrowLeftOutlined />
1454
+ </button>
1455
+ <button
1456
+ type="button"
1457
+ className={`steps-touch-nav steps-touch-nav-right${touchNavVisible ? ' is-visible' : ''}`}
1458
+ aria-label="Next step"
1459
+ disabled={activeStep >= steps.length - 1 || steps[activeStep]?.order_seqtype === 'E' || !isStepCompleted}
1460
+ onClick={() => {
1461
+ revealTouchNav();
1462
+ const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1463
+ if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1464
+ handleNext();
1465
+ }
1466
+ }}
1467
+ >
1468
+ <ArrowRightOutlined />
1469
+ </button>
1470
+ </>
1471
+ ) : null}
1230
1472
  <div
1231
1473
  key={`${currentProcessId}_${activeStep}`}
1232
1474
  className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
@@ -315,6 +315,81 @@
315
315
  overflow: hidden;
316
316
  padding: 10px 14px;
317
317
  box-sizing: border-box;
318
+ position: relative;
319
+ }
320
+
321
+ .steps-stage-body.is-swipe-enabled {
322
+ touch-action: pan-y;
323
+ }
324
+
325
+ /* ── Touch-device floating nav arrows ───────────────────── */
326
+
327
+ .steps-touch-nav {
328
+ position: absolute;
329
+ top: 50%;
330
+ z-index: 6;
331
+ width: 48px;
332
+ height: 48px;
333
+ display: inline-flex;
334
+ align-items: center;
335
+ justify-content: center;
336
+ padding: 0;
337
+ border: none;
338
+ border-radius: 50%;
339
+ background: rgba(30, 58, 138, 0.88);
340
+ color: #ffffff;
341
+ font-size: 18px;
342
+ cursor: pointer;
343
+ box-shadow: 0 6px 18px rgba(15, 23, 42, 0.22);
344
+ opacity: 0;
345
+ visibility: hidden;
346
+ transform: translateY(-50%) scale(0.85);
347
+ transition: opacity 220ms ease, transform 220ms ease, visibility 0s linear 220ms, background-color 160ms ease;
348
+ -webkit-tap-highlight-color: transparent;
349
+ touch-action: manipulation;
350
+ }
351
+
352
+ .steps-touch-nav:hover:not(:disabled),
353
+ .steps-touch-nav:focus-visible:not(:disabled) {
354
+ background: rgba(30, 58, 138, 1);
355
+ outline: none;
356
+ }
357
+
358
+ .steps-touch-nav:active:not(:disabled) {
359
+ transform: translateY(-50%) scale(0.9);
360
+ }
361
+
362
+ .steps-touch-nav:disabled {
363
+ background: rgba(148, 163, 184, 0.7);
364
+ cursor: not-allowed;
365
+ box-shadow: 0 3px 10px rgba(15, 23, 42, 0.1);
366
+ }
367
+
368
+ .steps-touch-nav-left {
369
+ left: 10px;
370
+ }
371
+
372
+ .steps-touch-nav-right {
373
+ right: 10px;
374
+ }
375
+
376
+ .steps-touch-nav.is-visible {
377
+ opacity: 1;
378
+ visibility: visible;
379
+ transform: translateY(-50%) scale(1);
380
+ transition: opacity 220ms ease, transform 220ms ease, visibility 0s linear 0s, background-color 160ms ease;
381
+ }
382
+
383
+ @media (prefers-reduced-motion: reduce) {
384
+ .steps-touch-nav {
385
+ transition: opacity 120ms linear, visibility 0s linear 120ms;
386
+ transform: translateY(-50%);
387
+ }
388
+
389
+ .steps-touch-nav.is-visible {
390
+ transform: translateY(-50%);
391
+ transition: opacity 120ms linear, visibility 0s linear 0s;
392
+ }
318
393
  }
319
394
 
320
395
  .steps-stage-body::-webkit-scrollbar {
@@ -665,6 +740,20 @@
665
740
  .steps-narration-bar {
666
741
  padding: 8px 12px;
667
742
  }
743
+
744
+ .steps-touch-nav {
745
+ width: 42px;
746
+ height: 42px;
747
+ font-size: 16px;
748
+ }
749
+
750
+ .steps-touch-nav-left {
751
+ left: 6px;
752
+ }
753
+
754
+ .steps-touch-nav-right {
755
+ right: 6px;
756
+ }
668
757
  }
669
758
 
670
759
  /* ── Reduced motion ─────────────────────────────────────── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-soxo-bootstrap-core",
3
- "version": "2.6.1-dev.30",
3
+ "version": "2.6.1-dev.31",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"