ui-soxo-bootstrap-core 2.6.40-dev.1 → 2.6.40-dev.11

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.
Files changed (419) hide show
  1. package/.babelrc +8 -8
  2. package/.github/workflows/npm-publish.yml +5 -4
  3. package/.husky/pre-commit +11 -11
  4. package/.prettierrc.json +10 -10
  5. package/DEVELOPER_GUIDE.md +323 -323
  6. package/babel.config.js +2 -2
  7. package/core/components/component-loader/component-loader.js +125 -125
  8. package/core/components/component-wrapper/component-wrapper.js +121 -121
  9. package/core/components/external-window/DEVELOPER_GUIDE.md +705 -705
  10. package/core/components/external-window/external-window.js +236 -236
  11. package/core/components/external-window/external-window.test.js +80 -80
  12. package/core/components/extra-info/extra-info-details.js +155 -155
  13. package/core/components/extra-info/extra-info-details.scss +26 -26
  14. package/core/components/extra-info/extra-info.js +134 -134
  15. package/core/components/index.js +12 -12
  16. package/core/components/landing-api/landing-api.js +707 -707
  17. package/core/components/landing-api/landing-api.scss +41 -41
  18. package/core/components/license-management/license-alert.js +97 -97
  19. package/core/components/menu-template-api/menu-template-api.js +321 -321
  20. package/core/components/root-application-api/root-application-api.js +174 -174
  21. package/core/index.js +13 -13
  22. package/core/lib/Store.js +369 -369
  23. package/core/lib/components/application-bootstrap/application-bootstrap.js +115 -115
  24. package/core/lib/components/approval-form/approval-form.js +280 -280
  25. package/core/lib/components/approval-form/approval-form.scss +183 -183
  26. package/core/lib/components/approval-list/approval-list.js +143 -143
  27. package/core/lib/components/approval-list/approval-list.scss +2 -2
  28. package/core/lib/components/approval-list/components/request-card/request-card.js +42 -42
  29. package/core/lib/components/approval-list/components/request-card/request-card.scss +30 -30
  30. package/core/lib/components/camera/camera.js +230 -230
  31. package/core/lib/components/camera/camera.scss +86 -86
  32. package/core/lib/components/comment-block/comment-block.js +138 -138
  33. package/core/lib/components/comment-block/comment-block.scss +3 -3
  34. package/core/lib/components/confirm-modal/confirm-modal.js +82 -82
  35. package/core/lib/components/confirm-modal/confirm-modal.scss +2 -2
  36. package/core/lib/components/consent/consent.js +67 -67
  37. package/core/lib/components/consent/pdf-signature.js +299 -299
  38. package/core/lib/components/consent/signature-pad.js +90 -90
  39. package/core/lib/components/consent/signature-pad.scss +14 -14
  40. package/core/lib/components/file-upload/file-upload.js +133 -133
  41. package/core/lib/components/finger-print-reader/finger-print-reader.js +295 -295
  42. package/core/lib/components/finger-print-reader/finger-print-reader.scss +47 -47
  43. package/core/lib/components/finger-print-search/finger-print-search.js +200 -200
  44. package/core/lib/components/finger-print-search/finger-print-search.scss +47 -47
  45. package/core/lib/components/global-header/animations.js +18 -18
  46. package/core/lib/components/global-header/global-header.js +286 -286
  47. package/core/lib/components/global-header/global-header.scss +397 -397
  48. package/core/lib/components/header/generic-header.js +76 -76
  49. package/core/lib/components/header/generic-header.scss +99 -99
  50. package/core/lib/components/image-preview/image-preview.js +33 -33
  51. package/core/lib/components/image-wrapper/image-wrapper.js +108 -108
  52. package/core/lib/components/image-wrapper/image-wrapper.scss +12 -12
  53. package/core/lib/components/index.js +206 -206
  54. package/core/lib/components/landing/landing.js +403 -403
  55. package/core/lib/components/language-switcher/language-switcher.js +49 -49
  56. package/core/lib/components/menu-context/menu-context.js +69 -69
  57. package/core/lib/components/menu-template/menu-template.js +249 -249
  58. package/core/lib/components/menu-template/menu-template.scss +9 -9
  59. package/core/lib/components/modal-search/modal-search.js +153 -153
  60. package/core/lib/components/modal-search/modal-search.scss +78 -78
  61. package/core/lib/components/modal-wrapper/modal-manager.js +15 -15
  62. package/core/lib/components/modal-wrapper/modal-wrapper.js +108 -108
  63. package/core/lib/components/modal-wrapper/modal-wrapper.scss +13 -13
  64. package/core/lib/components/notice-board/notice-board.js +132 -132
  65. package/core/lib/components/notice-board/notice-board.scss +65 -65
  66. package/core/lib/components/page-container/page-container.js +55 -55
  67. package/core/lib/components/page-container/page-container.scss +8 -8
  68. package/core/lib/components/page-header/page-header.js +23 -23
  69. package/core/lib/components/page-header/page-header.scss +17 -17
  70. package/core/lib/components/pdf-viewer/pdf-viewer.js +56 -56
  71. package/core/lib/components/portlet-table/components/table-actions/table-actions.js +58 -58
  72. package/core/lib/components/portlet-table/components/table-actions/table-actions.scss +1 -1
  73. package/core/lib/components/portlet-table/components/table-data/table-data.js +106 -106
  74. package/core/lib/components/portlet-table/portlet-table.js +63 -63
  75. package/core/lib/components/portlet-table/portlet-table.scss +90 -90
  76. package/core/lib/components/progress-bar/progress-bar.js +58 -58
  77. package/core/lib/components/progress-bar/progress-bar.scss +15 -15
  78. package/core/lib/components/request-form/request-form.js +110 -110
  79. package/core/lib/components/root-application/root-application.js +70 -70
  80. package/core/lib/components/rupee/rupee.js +14 -14
  81. package/core/lib/components/script-input/script-input.js +169 -169
  82. package/core/lib/components/script-input/script-input.scss +8 -8
  83. package/core/lib/components/sidemenu/animations.js +51 -51
  84. package/core/lib/components/sidemenu/sidemenu.js +713 -713
  85. package/core/lib/components/sidemenu/sidemenu.scss +314 -314
  86. package/core/lib/components/spotlight-search/spotlight-search.component.js +635 -635
  87. package/core/lib/components/spotlight-search/spotlight-search.component.scss +78 -78
  88. package/core/lib/components/table-wrapper/table-wrapper.js +135 -135
  89. package/core/lib/components/table-wrapper/table-wrapper.scss +72 -72
  90. package/core/lib/components/ui_elements/Loader.js +12 -12
  91. package/core/lib/components/ui_elements/Notify.js +12 -12
  92. package/core/lib/components/ui_elements/PlaceHolder.js +33 -33
  93. package/core/lib/components/web-camera/web-camera.js +161 -161
  94. package/core/lib/components/web-camera/web-camera.scss +28 -28
  95. package/core/lib/core.md +9 -9
  96. package/core/lib/elements/Elements.md +2 -2
  97. package/core/lib/elements/basic/LoggedUserRedirect.js +21 -21
  98. package/core/lib/elements/basic/PrivateRoute.js +16 -16
  99. package/core/lib/elements/basic/button/Button.md +43 -43
  100. package/core/lib/elements/basic/button/button.js +170 -170
  101. package/core/lib/elements/basic/card/Card.md +15 -15
  102. package/core/lib/elements/basic/card/card.js +40 -40
  103. package/core/lib/elements/basic/card/card.scss +13 -13
  104. package/core/lib/elements/basic/checkbox/checkbox.js +23 -23
  105. package/core/lib/elements/basic/col/col.js +15 -15
  106. package/core/lib/elements/basic/copy-to-clipboard/Readme.md +40 -40
  107. package/core/lib/elements/basic/copy-to-clipboard/copy-to-clipboard.js +61 -61
  108. package/core/lib/elements/basic/country-phone-input/Readme.md +98 -98
  109. package/core/lib/elements/basic/country-phone-input/country-phone-input.js +81 -81
  110. package/core/lib/elements/basic/country-phone-input/phone-input.scss +75 -75
  111. package/core/lib/elements/basic/datepicker/datepicker.js +33 -33
  112. package/core/lib/elements/basic/dragabble-wrapper/draggable-wrapper.js +203 -203
  113. package/core/lib/elements/basic/empty/empty.js +14 -14
  114. package/core/lib/elements/basic/fingerprint-protrected/fingerprint-protected.js +118 -118
  115. package/core/lib/elements/basic/fingerprint-protrected/fingerprint-protected.scss +10 -10
  116. package/core/lib/elements/basic/form/form.js +70 -70
  117. package/core/lib/elements/basic/form/form.scss +3 -3
  118. package/core/lib/elements/basic/image/image.js +45 -45
  119. package/core/lib/elements/basic/image/image.scss +17 -17
  120. package/core/lib/elements/basic/image/readme.md +26 -26
  121. package/core/lib/elements/basic/image-viewer/image-viewer.js +108 -108
  122. package/core/lib/elements/basic/image-viewer/image-viewer.scss +7 -7
  123. package/core/lib/elements/basic/input/input.js +81 -81
  124. package/core/lib/elements/basic/input/readme.md +77 -77
  125. package/core/lib/elements/basic/json-input/json-input.js +51 -51
  126. package/core/lib/elements/basic/menu-dashboard/menu-dashboard.js +216 -216
  127. package/core/lib/elements/basic/menu-dashboard/menu-dashboard.scss +28 -28
  128. package/core/lib/elements/basic/menu-tree/menu-tree.js +127 -127
  129. package/core/lib/elements/basic/modal/modal.js +64 -64
  130. package/core/lib/elements/basic/modal/readme.md +62 -62
  131. package/core/lib/elements/basic/popconfirm/popconfirm.js +17 -17
  132. package/core/lib/elements/basic/popover/popover.js +12 -12
  133. package/core/lib/elements/basic/radio/radio.js +18 -18
  134. package/core/lib/elements/basic/rangepicker/rangepicker.js +141 -141
  135. package/core/lib/elements/basic/rangepicker/rangepicker.scss +24 -24
  136. package/core/lib/elements/basic/rangepicker/readme.md +81 -81
  137. package/core/lib/elements/basic/reference-select/readme.md +18 -18
  138. package/core/lib/elements/basic/reference-select/reference-select.js +337 -337
  139. package/core/lib/elements/basic/row/row.js +15 -15
  140. package/core/lib/elements/basic/select/select.js +46 -46
  141. package/core/lib/elements/basic/select-box/readme.md +52 -52
  142. package/core/lib/elements/basic/select-box/select-box.js +63 -63
  143. package/core/lib/elements/basic/skeleton/readme.md +35 -35
  144. package/core/lib/elements/basic/skeleton/skeleton.js +35 -35
  145. package/core/lib/elements/basic/skeleton/skeleton.scss +53 -53
  146. package/core/lib/elements/basic/space/space.js +12 -12
  147. package/core/lib/elements/basic/switch/readme.md +29 -29
  148. package/core/lib/elements/basic/switch/switch.js +67 -67
  149. package/core/lib/elements/basic/tab/tab.js +14 -14
  150. package/core/lib/elements/basic/table/readme.md +8 -8
  151. package/core/lib/elements/basic/table/table.js +95 -95
  152. package/core/lib/elements/basic/tag/tag.js +63 -63
  153. package/core/lib/elements/basic/tag/tag.scss +2 -2
  154. package/core/lib/elements/basic/timeline/timeline.js +13 -13
  155. package/core/lib/elements/basic/title/readme.md +20 -20
  156. package/core/lib/elements/basic/title/title.js +37 -37
  157. package/core/lib/elements/basic/user-search/user-search.js +192 -192
  158. package/core/lib/elements/complex/barcode/barcode.js +27 -27
  159. package/core/lib/elements/complex/bargraph/bar-graph.js +262 -262
  160. package/core/lib/elements/complex/basic-table/basic-table.js +110 -110
  161. package/core/lib/elements/complex/basic-table/basic-table.scss +4 -4
  162. package/core/lib/elements/complex/date-display/date-display.js +37 -37
  163. package/core/lib/elements/complex/error-boundary/error-boundary.js +29 -29
  164. package/core/lib/elements/complex/google-location-input/map-container-library-load.js +92 -92
  165. package/core/lib/elements/complex/google-map/google-map.js +230 -230
  166. package/core/lib/elements/complex/google-map/google-map.scss +13 -13
  167. package/core/lib/elements/complex/line-graph/line-graph.js +108 -108
  168. package/core/lib/elements/complex/location-search-input/location-search-input.js +100 -100
  169. package/core/lib/elements/complex/pie-chart/pie-chart.js +202 -202
  170. package/core/lib/elements/complex/qr-code/qr-code.js +27 -27
  171. package/core/lib/elements/complex/qrscanner/qrscanner.js +57 -57
  172. package/core/lib/elements/complex/search-debounce/search-debounce.js +37 -37
  173. package/core/lib/elements/complex/statistic-card/dashboard-statistic-card.js +75 -75
  174. package/core/lib/elements/complex/statistic-card/statistic-card.js +28 -28
  175. package/core/lib/elements/index.js +226 -226
  176. package/core/lib/hooks/device-detect.js +25 -25
  177. package/core/lib/hooks/index.js +9 -9
  178. package/core/lib/hooks/use-location.js +33 -33
  179. package/core/lib/hooks/use-otp-timer.js +80 -80
  180. package/core/lib/hooks/use-window-size.js +34 -34
  181. package/core/lib/i18n.js +69 -69
  182. package/core/lib/index.js +106 -106
  183. package/core/lib/introduction.md +73 -73
  184. package/core/lib/js-styleguide.md +4112 -4112
  185. package/core/lib/models/actions/actions.js +127 -127
  186. package/core/lib/models/actions/components/action-detail/action-detail.js +190 -190
  187. package/core/lib/models/actions/components/custom-actions/custom-actions.js +185 -185
  188. package/core/lib/models/attachments/attachments.js +231 -231
  189. package/core/lib/models/base-loader.js +99 -99
  190. package/core/lib/models/base.js +716 -716
  191. package/core/lib/models/branches/branches.js +125 -125
  192. package/core/lib/models/checklists/checklists.js +114 -114
  193. package/core/lib/models/columns/columns.js +169 -169
  194. package/core/lib/models/columns/components/columns-add/columns-add.js +171 -171
  195. package/core/lib/models/comments/comments.js +213 -213
  196. package/core/lib/models/departments/departments.js +107 -107
  197. package/core/lib/models/financial-years/financial_years.js +127 -127
  198. package/core/lib/models/forms/components/form-creator/form-creator.js +665 -665
  199. package/core/lib/models/forms/components/form-creator/form-creator.scss +39 -39
  200. package/core/lib/models/forms/components/form-detail/form-detail.js +224 -224
  201. package/core/lib/models/forms/forms.js +121 -121
  202. package/core/lib/models/index.js +203 -203
  203. package/core/lib/models/invoice-numbers/invoice_numbers.js +204 -204
  204. package/core/lib/models/lookup-types/components/lookup-detail/lookup-detail.js +145 -145
  205. package/core/lib/models/lookup-types/lookup-types.js +113 -113
  206. package/core/lib/models/lookup-values/components/lookup-values-add/lookup-values-add.js +126 -126
  207. package/core/lib/models/lookup-values/lookup-values.js +107 -107
  208. package/core/lib/models/menu-roles/menu-roles.js +127 -127
  209. package/core/lib/models/menus/components/menu-add/menu-add.js +228 -228
  210. package/core/lib/models/menus/components/menu-detail/menu-detail.js +170 -170
  211. package/core/lib/models/menus/components/menu-list/menu-list.js +550 -550
  212. package/core/lib/models/menus/components/menu-list/menu-list.scss +5 -5
  213. package/core/lib/models/menus/components/menu-roles-add/menu-roles-add.js +183 -183
  214. package/core/lib/models/menus/menus.js +499 -499
  215. package/core/lib/models/models/components/model-detail/model-detail.js +137 -137
  216. package/core/lib/models/models/components/models.js +128 -128
  217. package/core/lib/models/modules/modules.js +204 -204
  218. package/core/lib/models/outbox/outbox.js +73 -73
  219. package/core/lib/models/pages/pages.js +107 -107
  220. package/core/lib/models/permissions/permissions.js +71 -71
  221. package/core/lib/models/process/components/process-add/process-add.js +181 -181
  222. package/core/lib/models/process/components/process-dashboard/process-dashboard.js +1068 -1068
  223. package/core/lib/models/process/components/process-dashboard/process-dashboard.scss +66 -66
  224. package/core/lib/models/process/components/process-detail/process-detail.js +140 -140
  225. package/core/lib/models/process/components/process-timeline/process-timeline.js +139 -139
  226. package/core/lib/models/process/components/task-detail/task-detail.js +240 -240
  227. package/core/lib/models/process/components/task-detail/task-detail.scss +27 -27
  228. package/core/lib/models/process/components/task-form/task-form.js +528 -528
  229. package/core/lib/models/process/components/task-form/task-form.scss +7 -7
  230. package/core/lib/models/process/components/task-list/task-list.js +221 -221
  231. package/core/lib/models/process/components/task-list/task-list.scss +14 -14
  232. package/core/lib/models/process/components/task-overview/task-overview.js +299 -299
  233. package/core/lib/models/process/components/task-overview-legacy/task-overview-legacy.js +192 -192
  234. package/core/lib/models/process/components/task-routes/task-routes.js +45 -45
  235. package/core/lib/models/process/components/task-status/task-status.js +175 -175
  236. package/core/lib/models/process/components/task-status/task-status.scss +11 -11
  237. package/core/lib/models/process/process.js +780 -780
  238. package/core/lib/models/process-transactions/process-transactions.js +123 -123
  239. package/core/lib/models/roles/roles.js +106 -106
  240. package/core/lib/models/scripts/scripts.js +111 -111
  241. package/core/lib/models/step-transactions/step-transcations.js +147 -147
  242. package/core/lib/models/steps/components/step-add/step-add.js +261 -261
  243. package/core/lib/models/steps/components/step-detail/step-detail.js +157 -157
  244. package/core/lib/models/steps/steps.js +356 -356
  245. package/core/lib/models/user-preferences/user-preferences.js +83 -83
  246. package/core/lib/models/users/components/user-add/user-add.js +226 -226
  247. package/core/lib/models/users/users.js +119 -119
  248. package/core/lib/modules/business/launch-page/launch-page.js +29 -29
  249. package/core/lib/modules/business/launch-page/launch-page.scss +5 -5
  250. package/core/lib/modules/business/slots/slots.js +231 -231
  251. package/core/lib/modules/business/slots/slots.scss +108 -108
  252. package/core/lib/modules/forms/components/field-customizer/field-customizer.js +138 -138
  253. package/core/lib/modules/forms/components/field-selector/field-selector.js +157 -157
  254. package/core/lib/modules/forms/components/field-selector/field-selector.scss +25 -25
  255. package/core/lib/modules/forms/components/form-display/form-display.js +203 -203
  256. package/core/lib/modules/forms/components/form-display/form-display.scss +9 -9
  257. package/core/lib/modules/forms/components/tab-customizer/tab-customizer.js +124 -124
  258. package/core/lib/modules/generic/generic-add/generic-add.js +213 -213
  259. package/core/lib/modules/generic/generic-detail/generic-detail.js +199 -199
  260. package/core/lib/modules/generic/generic-edit/generic-edit.js +120 -120
  261. package/core/lib/modules/generic/generic-list/ExportReactCSV.js +414 -414
  262. package/core/lib/modules/generic/generic-list/generic-list.js +705 -705
  263. package/core/lib/modules/generic/generic-list/generic-list.scss +68 -68
  264. package/core/lib/modules/generic/generic-upload/generic-upload.js +483 -483
  265. package/core/lib/modules/generic/table-settings/table-settings.js +226 -226
  266. package/core/lib/modules/generic/table-settings/table-settings.scss +37 -37
  267. package/core/lib/modules/index.js +52 -52
  268. package/core/lib/modules/modules-routes/module-routes.js +35 -35
  269. package/core/lib/pages/change-password/change-password.js +204 -204
  270. package/core/lib/pages/change-password/change-password.scss +73 -73
  271. package/core/lib/pages/homepage/homepage.js +53 -53
  272. package/core/lib/pages/index.js +19 -19
  273. package/core/lib/pages/login/commnication-mode-selection.js +46 -46
  274. package/core/lib/pages/login/communication-mode-selection.scss +60 -60
  275. package/core/lib/pages/login/login.js +872 -872
  276. package/core/lib/pages/login/login.scss +353 -353
  277. package/core/lib/pages/login/reset-password.js +124 -124
  278. package/core/lib/pages/login/reset-password.scss +31 -31
  279. package/core/lib/pages/manage-users/manage-users.js +429 -429
  280. package/core/lib/pages/manage-users/manage-users.scss +25 -25
  281. package/core/lib/pages/profile/profile.js +247 -247
  282. package/core/lib/pages/profile/profile.scss +107 -107
  283. package/core/lib/pages/profile/theme-config.js +18 -18
  284. package/core/lib/pages/profile/themes-backup.json +310 -310
  285. package/core/lib/pages/profile/themes.json +254 -254
  286. package/core/lib/pages/register/register.js +176 -176
  287. package/core/lib/pages/register/register.scss +128 -128
  288. package/core/lib/react-styleguide.md +756 -756
  289. package/core/lib/utils/api/api.utils.js +207 -207
  290. package/core/lib/utils/api/readme.md +426 -426
  291. package/core/lib/utils/async.js +35 -35
  292. package/core/lib/utils/common/common.utils.js +237 -237
  293. package/core/lib/utils/common/readme.md +30 -30
  294. package/core/lib/utils/date/date.utils.js +295 -295
  295. package/core/lib/utils/date/readme.md +2 -2
  296. package/core/lib/utils/firebase.support.utils.js +98 -98
  297. package/core/lib/utils/firebase.utils.js +808 -808
  298. package/core/lib/utils/font-awesome.utils.js +168 -168
  299. package/core/lib/utils/form/form.utils.js +255 -255
  300. package/core/lib/utils/generic/generic.utils.js +70 -70
  301. package/core/lib/utils/http/auth.helper.js +95 -95
  302. package/core/lib/utils/http/http.utils.js +186 -186
  303. package/core/lib/utils/http/readme.md +14 -14
  304. package/core/lib/utils/index.js +43 -43
  305. package/core/lib/utils/location/location.utils.js +137 -137
  306. package/core/lib/utils/location/readme.md +18 -18
  307. package/core/lib/utils/modal.utils.js +15 -15
  308. package/core/lib/utils/notification.utils.js +34 -34
  309. package/core/lib/utils/pwa/pwa.utils.js +88 -88
  310. package/core/lib/utils/script.utils.js +235 -235
  311. package/core/lib/utils/setting.utils.js +68 -68
  312. package/core/lib/utils/upload.utils.js +29 -29
  313. package/core/models/Preference/Preferences.js +46 -46
  314. package/core/models/base/base.js +403 -403
  315. package/core/models/base-clone-loader.js +107 -107
  316. package/core/models/base-clone.js +187 -187
  317. package/core/models/base-loader.js +97 -97
  318. package/core/models/core-scripts/core-scripts.js +179 -179
  319. package/core/models/dashboard/dashboard.js +201 -201
  320. package/core/models/detail-loader.js +88 -88
  321. package/core/models/doctor/components/doctor-add/doctor-add.js +432 -432
  322. package/core/models/doctor/components/doctor-add/doctor-add.scss +32 -32
  323. package/core/models/groups.js +82 -82
  324. package/core/models/index.js +100 -100
  325. package/core/models/lookup-types/components/lookup-detail/lookup-detail.js +129 -129
  326. package/core/models/lookup-types/lookup-types.js +96 -96
  327. package/core/models/lookup-values/components/lookup-values-modal/lookup-values-modal.js +95 -95
  328. package/core/models/lookup-values/lookup-values.js +92 -92
  329. package/core/models/menu-roles/components/menu-roles-add/menu-roles-add.js +153 -153
  330. package/core/models/menu-roles/menu-roles.js +158 -158
  331. package/core/models/menus/components/menu-add/menu-add.js +288 -288
  332. package/core/models/menus/components/menu-add/menu-add.scss +31 -31
  333. package/core/models/menus/components/menu-detail/menu-detail.js +263 -263
  334. package/core/models/menus/components/menu-list/menu-list.js +392 -392
  335. package/core/models/menus/components/menu-lists/menu-lists.js +584 -584
  336. package/core/models/menus/components/menu-lists/menu-lists.scss +46 -46
  337. package/core/models/menus/menus.js +338 -338
  338. package/core/models/model-columns.js +121 -121
  339. package/core/models/models/components/model-detail/model-add.js +120 -120
  340. package/core/models/models/components/model-detail/model-detail.js +133 -133
  341. package/core/models/models/models.js +154 -154
  342. package/core/models/pages/components/page-add/page-add.js +163 -163
  343. package/core/models/pages/components/page-add/page-add.scss +30 -30
  344. package/core/models/pages/components/page-details/page-details.js +209 -209
  345. package/core/models/pages/components/page-list/page-list.js +248 -248
  346. package/core/models/pages/pages.js +142 -142
  347. package/core/models/pages.js +142 -142
  348. package/core/models/roles/components/role-add/menu-label.js +14 -14
  349. package/core/models/roles/components/role-add/menu-tree.js +127 -127
  350. package/core/models/roles/components/role-add/role-add.js +222 -222
  351. package/core/models/roles/components/role-add/role-add.scss +4 -4
  352. package/core/models/roles/components/role-list/role-list.js +406 -406
  353. package/core/models/roles/roles.js +196 -196
  354. package/core/models/staff/components/staff-add/staff-add.js +455 -455
  355. package/core/models/user-roles/components/user-roles-add/user-roles-add.js +149 -149
  356. package/core/models/user-roles/user-roles.js +113 -113
  357. package/core/models/users/components/assign-role/assign-role.js +428 -428
  358. package/core/models/users/components/assign-role/assign-role.scss +281 -281
  359. package/core/models/users/components/assign-role/avatar-props.js +45 -45
  360. package/core/models/users/components/user-add/user-add.js +847 -847
  361. package/core/models/users/components/user-add/user-edit.js +110 -110
  362. package/core/models/users/components/user-detail/user-detail.js +236 -236
  363. package/core/models/users/components/user-list/user-list.js +397 -397
  364. package/core/models/users/users.js +379 -379
  365. package/core/modules/Informations/change-info/change-info.js +618 -618
  366. package/core/modules/Informations/change-info/change-info.scss +134 -134
  367. package/core/modules/dashboard/components/dashboard-card/animations.js +64 -64
  368. package/core/modules/dashboard/components/dashboard-card/dashboard-card.js +197 -197
  369. package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.js +430 -430
  370. package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.scss +59 -59
  371. package/core/modules/dashboard/components/pop-query-dashboard/pop-query-dashboard.js +66 -66
  372. package/core/modules/generic/components/generic-add/generic-add.js +121 -121
  373. package/core/modules/generic/components/generic-add/generic-add.scss +13 -13
  374. package/core/modules/generic/components/generic-add-modal/generic-add-modal.js +125 -125
  375. package/core/modules/generic/components/generic-add-modal/generic-add-modal.scss +13 -13
  376. package/core/modules/generic/components/generic-detail/generic-detail.js +184 -184
  377. package/core/modules/generic/components/generic-detail/generic-detail.scss +25 -25
  378. package/core/modules/generic/components/generic-edit/generic-edit.js +123 -123
  379. package/core/modules/generic/components/generic-list/generic-list.js +335 -335
  380. package/core/modules/generic/components/generic-list/generic-list.scss +35 -35
  381. package/core/modules/index.js +42 -42
  382. package/core/modules/module-routes/module-routes.js +37 -37
  383. package/core/modules/reporting/components/index.js +6 -6
  384. package/core/modules/reporting/components/reporting-dashboard/README.md +316 -316
  385. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.js +271 -271
  386. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.scss +76 -76
  387. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.js +90 -90
  388. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.test.js +74 -74
  389. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +449 -449
  390. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.test.js +199 -199
  391. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +1116 -1116
  392. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.scss +215 -215
  393. package/core/modules/reporting/components/reporting-dashboard/reporting-table.js +519 -519
  394. package/core/modules/steps/action-buttons.js +92 -92
  395. package/core/modules/steps/action-buttons.scss +62 -62
  396. package/core/modules/steps/chat-assistant.js +141 -141
  397. package/core/modules/steps/narration.js +192 -192
  398. package/core/modules/steps/openai-realtime.js +275 -275
  399. package/core/modules/steps/progress-storage.js +140 -140
  400. package/core/modules/steps/readme.md +167 -167
  401. package/core/modules/steps/steps.js +1567 -1567
  402. package/core/modules/steps/steps.scss +907 -907
  403. package/core/modules/steps/timeline.js +56 -56
  404. package/core/modules/steps/voice-navigation.js +709 -709
  405. package/core/pages/homepage-api/homepage-api.js +106 -106
  406. package/core/pages/homepage-api/homepage-api.scss +233 -233
  407. package/core/pages/homepage-api/menu-dashboard.js +169 -169
  408. package/core/pages/homepage-api/menu-dashboard.scss +11 -11
  409. package/core/translation.json +53 -53
  410. package/core/translations.json +19 -19
  411. package/core/utils/script.utils.js +129 -129
  412. package/core/utils/settings.utils.js +25 -25
  413. package/eslint.config.mjs +79 -79
  414. package/index.js +35 -35
  415. package/jest.config.js +7 -7
  416. package/jest.setup.js +1 -1
  417. package/package.json +124 -124
  418. package/tsconfig.json +26 -26
  419. package/webpack.config.js +173 -173
@@ -1,1567 +1,1567 @@
1
- /**
2
- * ProcessStepsPage Component
3
- *
4
- * - Manages a multi-step, time-tracked process workflow.
5
- * - Dynamically renders step-specific components based on configuration.
6
- * - Tracks step and process durations with local persistence support.
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.
10
- * - Handles process submission and optional chaining to the next process.
11
- * - Renders a single active step view with compact breadcrumb controls.
12
- */
13
- import { ArrowLeftOutlined, ArrowRightOutlined, CompressOutlined, ExpandOutlined, SoundOutlined } from '@ant-design/icons';
14
- import { Empty, Select, Spin, message } from 'antd';
15
- import moment from 'moment';
16
- import { useEffect, useRef, useState } from 'react';
17
- import { ExternalWindow } from '../../components';
18
- import { Dashboard } from '../../models';
19
- import * as genericComponents from './../../lib';
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';
30
- import { createOpenAIRealtimeSession, hasOpenAIRealtimeCredentials } from './openai-realtime';
31
- import { clearProgressEntry, readProgressEntry, sweepStaleProgressKeys, writeProgressEntry } from './progress-storage';
32
- import './steps.scss';
33
-
34
- const TOUCH_NAV_HIDE_DELAY = 2800;
35
- const SWIPE_DISTANCE_THRESHOLD = 60;
36
- const SWIPE_VERTICAL_TOLERANCE = 80;
37
-
38
- /**
39
- * First-step CTA labels keyed by normalized process name.
40
- * - Keys are the lowercased/trimmed process name returned by the backend.
41
- * - Missing keys fall back to the generic 'Next' label at the call site.
42
- * - Frozen so accidental mutation during render doesn't leak across renders.
43
- */
44
- const FIRST_STEP_LABELS = Object.freeze({
45
- verification: 'Verify Profile',
46
- consultation: 'Start Consultation',
47
- });
48
-
49
- const VOICE_PROVIDER_OPTIONS = [
50
- { label: 'Gemini', value: 'gemini' },
51
- { label: 'ElevenLabs', value: 'elevenlabs' },
52
- { label: 'OpenAI', value: 'openai' },
53
- ];
54
-
55
- const GEMINI_VOICE_OPTIONS = [{ label: 'Kore', value: 'Kore' }];
56
- const DEFAULT_GEMINI_TTS_VOICE = process.env.GEMINI_TTS_VOICE || process.env.REACT_APP_GEMINI_TTS_VOICE || GEMINI_VOICE_OPTIONS[0].value;
57
- const OPENAI_TTS_VOICE_OPTIONS = [
58
- { label: 'Alloy', value: 'alloy' },
59
- { label: 'Ash', value: 'ash' },
60
- { label: 'Coral', value: 'coral' },
61
- { label: 'Echo', value: 'echo' },
62
- { label: 'Fable', value: 'fable' },
63
- { label: 'Nova', value: 'nova' },
64
- { label: 'Onyx', value: 'onyx' },
65
- { label: 'Sage', value: 'sage' },
66
- { label: 'Shimmer', value: 'shimmer' },
67
- ];
68
- const DEFAULT_OPENAI_TTS_VOICE =
69
- process.env.OPENAI_TTS_VOICE ||
70
- process.env.REACT_APP_OPENAI_TTS_VOICE ||
71
- process.env.OPENAI_REALTIME_VOICE ||
72
- process.env.REACT_APP_OPENAI_REALTIME_VOICE ||
73
- OPENAI_TTS_VOICE_OPTIONS[0].value;
74
-
75
- const ELEVENLABS_VOICE_OPTIONS = [
76
- { label: 'Rachel', value: '21m00Tcm4TlvDq8ikWAM' },
77
- { label: 'Adam', value: 'pNInz6obpgDQGcFmaJgB' },
78
- { label: 'Bella', value: 'EXAVITQu4vr4xnSDxMaL' },
79
- { label: 'Antoni', value: 'ErXwobaYiN019PkySvjV' },
80
- { label: 'Josh', value: 'TxGEqnHWrfWFTfGW9XjX' },
81
- ];
82
- const DEFAULT_ELEVENLABS_VOICE_ID =
83
- process.env.ELEVENLABS_VOICE_ID ||
84
- process.env.ELEVEN_LABS_VOICE_ID ||
85
- process.env.REACT_APP_ELEVENLABS_VOICE_ID ||
86
- ELEVENLABS_VOICE_OPTIONS[0].value;
87
-
88
- const SARVAM_VOICE_OPTIONS = [
89
- { label: 'Anushka', value: 'anushka' },
90
- { label: 'Manisha', value: 'manisha' },
91
- { label: 'Vidya', value: 'vidya' },
92
- { label: 'Arya', value: 'arya' },
93
- { label: 'Karun', value: 'karun' },
94
- { label: 'Hitesh', value: 'hitesh' },
95
- ];
96
-
97
- const ELEVENLABS_TTS_API_BASE_URL =
98
- process.env.ELEVENLABS_TTS_API_BASE_URL || process.env.REACT_APP_ELEVENLABS_TTS_API_BASE_URL || 'https://api.elevenlabs.io/v1/text-to-speech';
99
- const ELEVENLABS_MODEL_ID = process.env.ELEVENLABS_MODEL_ID || process.env.REACT_APP_ELEVENLABS_MODEL_ID || 'eleven_multilingual_v2';
100
- const ELEVENLABS_OUTPUT_FORMAT = process.env.ELEVENLABS_OUTPUT_FORMAT || process.env.REACT_APP_ELEVENLABS_OUTPUT_FORMAT || 'mp3_44100_128';
101
- const GEMINI_TTS_MODEL = process.env.GEMINI_TTS_MODEL || process.env.REACT_APP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-preview-tts';
102
- const GEMINI_TTS_API_BASE_URL =
103
- process.env.GEMINI_API_BASE_URL || process.env.REACT_APP_GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
104
- const OPENAI_TTS_ENDPOINT = process.env.OPENAI_TTS_ENDPOINT || process.env.REACT_APP_OPENAI_TTS_ENDPOINT || 'https://api.openai.com/v1/audio/speech';
105
- const OPENAI_TTS_MODEL = process.env.OPENAI_TTS_MODEL || process.env.REACT_APP_OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
106
- const OPENAI_TTS_FORMAT = process.env.OPENAI_TTS_FORMAT || process.env.REACT_APP_OPENAI_TTS_FORMAT || 'mp3';
107
- const SARVAM_TTS_ENDPOINT = process.env.SARVAM_TTS_ENDPOINT || process.env.REACT_APP_SARVAM_TTS_ENDPOINT || 'https://api.sarvam.ai/text-to-speech';
108
- const SARVAM_TTS_MODEL = process.env.SARVAM_TTS_MODEL || process.env.REACT_APP_SARVAM_TTS_MODEL || 'bulbul:v2';
109
- const SARVAM_TARGET_LANGUAGE_CODE = process.env.SARVAM_TARGET_LANGUAGE_CODE || process.env.REACT_APP_SARVAM_TARGET_LANGUAGE_CODE || 'en-IN';
110
- const SARVAM_OUTPUT_AUDIO_CODEC = process.env.SARVAM_OUTPUT_AUDIO_CODEC || process.env.REACT_APP_SARVAM_OUTPUT_AUDIO_CODEC || 'wav';
111
- const NARRATION_CONTROLS_ENABLED = false;
112
-
113
- export default function ProcessStepsPage({ match, CustomComponents = {}, ...props }) {
114
- const allComponents = { ...genericComponents, ...CustomComponents };
115
- const GuestInfoComponent = allComponents.EntryInfo;
116
-
117
- const [loading, setLoading] = useState(false);
118
- const [steps, setSteps] = useState([]);
119
- const [processName, setProcessName] = useState(null);
120
- const [activeStep, setActiveStep] = useState(0);
121
- const [resumableStep, setResumableStep] = useState(null);
122
- const [isStepCompleted, setIsStepCompleted] = useState(false);
123
-
124
- const [nextProcessId, setNextProcessId] = useState(null);
125
- const [previousProcessId, setPreviousProcessId] = useState(null);
126
- const [stepStartTime, setStepStartTime] = useState(null);
127
- const [processStartTime, setProcessStartTime] = useState(null);
128
- const [processTimings, setProcessTimings] = useState([]);
129
- const [showExternalWindow, setShowExternalWindow] = useState(false);
130
- const [externalWin, setExternalWin] = useState(null);
131
- const [autoNarration, setAutoNarration] = useState(NARRATION_CONTROLS_ENABLED);
132
- const [voiceProvider, setVoiceProvider] = useState(
133
- process.env.REACT_APP_STEP_TTS_PROVIDER && process.env.REACT_APP_STEP_TTS_PROVIDER !== 'browser'
134
- ? process.env.REACT_APP_STEP_TTS_PROVIDER
135
- : 'gemini'
136
- );
137
- const [browserVoiceOptions, setBrowserVoiceOptions] = useState([]);
138
- const [voiceSelections, setVoiceSelections] = useState({
139
- browser: process.env.REACT_APP_STEP_BROWSER_VOICE || process.env.REACT_APP_STEP_TTS_VOICE || '',
140
- gemini: DEFAULT_GEMINI_TTS_VOICE,
141
- elevenlabs: DEFAULT_ELEVENLABS_VOICE_ID,
142
- openai: DEFAULT_OPENAI_TTS_VOICE,
143
- sarvam: process.env.REACT_APP_SARVAM_SPEAKER || SARVAM_VOICE_OPTIONS[0].value,
144
- });
145
- const [stepSlideDirection, setStepSlideDirection] = useState('forward');
146
- const [showNextProcessAction, setShowNextProcessAction] = useState(false);
147
- const [isStepFullscreen, setIsStepFullscreen] = useState(false);
148
- const [realtimeStatus, setRealtimeStatus] = useState('idle');
149
- const [isTouchDevice, setIsTouchDevice] = useState(false);
150
- const [touchNavVisible, setTouchNavVisible] = useState(false);
151
-
152
- const narrationUtteranceRef = useRef(null);
153
- const narrationAudioRef = useRef(null);
154
- const narrationAudioUrlRef = useRef(null);
155
- const narrationFallbackNoticeRef = useRef(false);
156
- const realtimeSessionRef = useRef(null);
157
- const fullscreenViewportRef = useRef(null);
158
- const touchStartRef = useRef(null);
159
- const touchNavHideTimeoutRef = useRef(null);
160
-
161
- const urlParams = Location.search();
162
- const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
163
- let processId = urlParams.processId;
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
-
196
- // Load process details based on the current process ID
197
- useEffect(() => {
198
- sweepStaleProgressKeys();
199
-
200
- loadProcess(currentProcessId);
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 : [];
212
- setProcessTimings(savedTimings);
213
-
214
- const savedStepEntry = readProgressEntry(activeStepStorageKey);
215
- const parsedStep = savedStepEntry && savedStepEntry.guestRef === guestReference ? Number(savedStepEntry.step) : NaN;
216
- const savedActiveStep = Number.isFinite(parsedStep) && parsedStep > 0 ? parsedStep : 0;
217
- setResumableStep(savedActiveStep > 0 ? savedActiveStep : null);
218
-
219
- setProcessStartTime(Date.now());
220
- setStepStartTime(Date.now());
221
- setShowNextProcessAction(false);
222
- setActiveStep(0);
223
- }, [currentProcessId, guestReference]);
224
-
225
- /**
226
- * The active step is persisted inline in `gotoStep` rather than via a
227
- * useEffect — running it as an effect captured `activeStepStorageKey` from
228
- * a closure that could go stale during URL changes, allowing one guest's
229
- * progress to be written under another guest's key. Writing in `gotoStep`
230
- * happens synchronously with the user action using the current render's
231
- * key, so it can never target a different guest than the one interacting.
232
- */
233
-
234
- /**
235
- * Sync the loaded process name into the address bar.
236
- * - Mirrors `processName` into a `process` query parameter so deep-links and
237
- * refreshes carry the human-readable process label.
238
- * - Uses `window.history.replaceState` to avoid a navigation event, which
239
- * keeps React Router state and component instances stable.
240
- * - Removes the param when `processName` is null/empty so stale values do
241
- * not linger after a process clears.
242
- */
243
- useEffect(() => {
244
- if (typeof window === 'undefined') {
245
- return;
246
- }
247
-
248
- const params = new URLSearchParams(window.location.search);
249
- let changed = false;
250
-
251
- const trimmedName = typeof processName === 'string' ? processName.trim() : '';
252
- if (trimmedName) {
253
- if (params.get('process') !== trimmedName) {
254
- params.set('process', trimmedName);
255
- changed = true;
256
- }
257
- } else if (params.has('process')) {
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;
282
- }
283
-
284
- const search = params.toString();
285
- const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash || ''}`;
286
- window.history.replaceState(window.history.state, '', newUrl);
287
- }, [processName, currentProcessId]);
288
-
289
- //// Reset step start time whenever the active step changes
290
-
291
- useEffect(() => {
292
- setStepStartTime(Date.now());
293
- }, [activeStep]);
294
-
295
- // Check whether the current step is completed or mandatory
296
- useEffect(() => {
297
- if (steps.length > 0) {
298
- setIsStepCompleted(steps[activeStep]?.is_mandatory !== true);
299
- }
300
- }, [activeStep, steps]);
301
-
302
- useEffect(() => {
303
- if (steps[activeStep]?.order_seqtype !== 'E') {
304
- setShowNextProcessAction(false);
305
- }
306
- }, [activeStep, steps]);
307
-
308
- useEffect(() => {
309
- if (typeof window === 'undefined' || !window.speechSynthesis) {
310
- return undefined;
311
- }
312
-
313
- const updateBrowserVoices = () => {
314
- const voices = window.speechSynthesis
315
- .getVoices()
316
- .map((voice) => ({
317
- label: `${voice.name} (${voice.lang})`,
318
- value: voice.voiceURI || voice.name,
319
- }))
320
- .sort((voiceA, voiceB) => voiceA.label.localeCompare(voiceB.label));
321
-
322
- setBrowserVoiceOptions(voices);
323
-
324
- if (voices.length) {
325
- setVoiceSelections((oldSelections) => {
326
- if (oldSelections.browser) {
327
- return oldSelections;
328
- }
329
-
330
- return {
331
- ...oldSelections,
332
- browser: voices[0].value,
333
- };
334
- });
335
- }
336
- };
337
-
338
- updateBrowserVoices();
339
- if (typeof window.speechSynthesis.addEventListener === 'function') {
340
- window.speechSynthesis.addEventListener('voiceschanged', updateBrowserVoices);
341
- } else {
342
- window.speechSynthesis.onvoiceschanged = updateBrowserVoices;
343
- }
344
-
345
- return () => {
346
- if (typeof window.speechSynthesis.removeEventListener === 'function') {
347
- window.speechSynthesis.removeEventListener('voiceschanged', updateBrowserVoices);
348
- } else if (window.speechSynthesis.onvoiceschanged === updateBrowserVoices) {
349
- window.speechSynthesis.onvoiceschanged = null;
350
- }
351
- };
352
- }, []);
353
-
354
- useEffect(() => {
355
- narrationFallbackNoticeRef.current = false;
356
- }, [voiceProvider]);
357
-
358
- useEffect(() => {
359
- const isSupportedProvider = VOICE_PROVIDER_OPTIONS.some((option) => option.value === voiceProvider);
360
-
361
- if (!isSupportedProvider) {
362
- setVoiceProvider('gemini');
363
- }
364
- }, [voiceProvider]);
365
-
366
- useEffect(() => {
367
- stopNarration();
368
- }, [voiceProvider, voiceSelections.browser, voiceSelections.gemini, voiceSelections.elevenlabs, voiceSelections.openai, voiceSelections.sarvam]);
369
-
370
- useEffect(() => {
371
- const providerVoices =
372
- voiceProvider === 'gemini'
373
- ? GEMINI_VOICE_OPTIONS
374
- : voiceProvider === 'elevenlabs'
375
- ? ELEVENLABS_VOICE_OPTIONS
376
- : voiceProvider === 'openai'
377
- ? OPENAI_TTS_VOICE_OPTIONS
378
- : SARVAM_VOICE_OPTIONS;
379
-
380
- if (!providerVoices.length) {
381
- return;
382
- }
383
-
384
- setVoiceSelections((oldSelections) => {
385
- if (oldSelections[voiceProvider]) {
386
- return oldSelections;
387
- }
388
-
389
- return {
390
- ...oldSelections,
391
- [voiceProvider]: providerVoices[0].value,
392
- };
393
- });
394
- }, [voiceProvider, browserVoiceOptions]);
395
-
396
- // Save updated process timings to state and localStorage
397
- const saveTimings = (updated) => {
398
- const safeTimings = Array.isArray(updated) ? updated : [];
399
- setProcessTimings(safeTimings);
400
- writeProgressEntry(timingsStorageKey, { guestRef: guestReference, timings: safeTimings });
401
- };
402
- // Record time spent on the current step
403
-
404
- const recordStepTime = (status = 'completed') => {
405
- // Exit if step start time or step data is missing
406
-
407
- if (!stepStartTime || !steps[activeStep]) return processTimings;
408
- // Capture end time and calculate duration
409
-
410
- const endTime = Date.now();
411
- const duration = endTime - stepStartTime;
412
- const stepId = steps[activeStep].step_id;
413
- // Clone existing timings
414
-
415
- const previousTimings = Array.isArray(processTimings) ? processTimings : [];
416
- const updated = [...previousTimings];
417
- const index = updated.findIndex((t) => t.step_id === stepId);
418
- // Create timing entry for the step
419
-
420
- const entry = {
421
- step_id: stepId,
422
- start_time: moment(stepStartTime).format('DD-MM-YYYY HH:mm:ss'),
423
- end_time: moment(endTime).format('DD-MM-YYYY HH:mm:ss'),
424
- duration,
425
- status,
426
- };
427
- // Update existing entry or add a new one
428
- if (index > -1) {
429
- updated[index] = { ...updated[index], ...entry, duration: updated[index].duration + duration };
430
- } else {
431
- updated.push(entry);
432
- }
433
-
434
- return updated;
435
- };
436
-
437
- /**
438
- * @param {*} processId
439
- *
440
- * Process Loading
441
- * - Fetches process details and step configuration using the process ID.
442
- * - Manages loading state during the API call.
443
- * - Stores step data and prepares next process details if available.
444
- * - Handles API errors and maintains UI stability.
445
- */
446
- async function loadProcess(processId) {
447
- setLoading(true);
448
- setNextProcessId(null);
449
- setPreviousProcessId(null);
450
-
451
- try {
452
- const result = await Dashboard.loadProcess(processId);
453
-
454
- setSteps(result?.data?.steps || []);
455
- setProcessName(result?.data?.process_name ?? null);
456
- if (result?.data?.next_process_id) setNextProcessId(result.data);
457
- if (result?.data?.previous_process_id) setPreviousProcessId(result.data);
458
- } catch (e) {
459
- console.error('Error loading process steps:', e);
460
- } finally {
461
- setLoading(false);
462
- }
463
- }
464
- /**
465
- * @param {*} finalTimings
466
- *
467
- * Process Submission
468
- * - Builds payload with process metadata, reference details, and step timings.
469
- * - Submits process completion data to the backend.
470
- * - Clears stored timings on successful submission.
471
- * - Persists timing data locally if submission fails.
472
- */
473
- const handleProcessSubmit = async (finalTimings) => {
474
- const payload = {
475
- process_id: currentProcessId,
476
- status: 'completed',
477
- reference_id: urlParams?.opb_id || urlParams?.reference_id,
478
- reference_number: urlParams?.opno || urlParams?.reference_number,
479
- mode: urlParams?.mode,
480
- process: {
481
- process_start_time: moment(processStartTime).format('DD-MM-YYYY HH:mm'),
482
- process_end_time: moment().format('DD-MM-YYYY HH:mm'),
483
- steps: finalTimings,
484
- },
485
- };
486
-
487
- try {
488
- const response = await Dashboard.processLog(payload);
489
-
490
- if (response.success) {
491
- clearProgressEntry(timingsStorageKey);
492
- clearProgressEntry(activeStepStorageKey);
493
- setProcessTimings([]);
494
- setResumableStep(null);
495
- return true;
496
- }
497
- } catch (e) {
498
- console.error('Error:', e);
499
- saveTimings(finalTimings);
500
- }
501
- return false;
502
- };
503
- /**
504
- * @param {number} index
505
- * @param {string} status
506
- *
507
- * Step Navigation
508
- * - Records time spent on the current step.
509
- * - Saves updated step timing data.
510
- * - Navigates to the specified step index.
511
- */
512
- const gotoStep = (index, status = 'completed') => {
513
- if (!steps.length) {
514
- return;
515
- }
516
-
517
- const nextIndex = Math.max(0, Math.min(index, steps.length - 1));
518
-
519
- if (nextIndex === activeStep) {
520
- return;
521
- }
522
-
523
- setStepSlideDirection(nextIndex > activeStep ? 'forward' : 'backward');
524
-
525
- const updated = recordStepTime(status);
526
- saveTimings(updated);
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
- }
541
- };
542
- /**
543
- * Navigate to the next step
544
- * - Records timing data and advances step index by one.
545
- */
546
- const handleNext = () => gotoStep(activeStep + 1);
547
- /**
548
- * Navigate to the previous step
549
- * - Records timing data and moves to the previous step.
550
- */
551
- const handlePrevious = () => gotoStep(activeStep - 1);
552
- /**
553
- * Skip current step
554
- * - Records timing with skipped status.
555
- * - Moves to the next step.
556
- */
557
- const handleSkip = () => gotoStep(activeStep + 1, 'skipped');
558
- /**
559
- * Breadcrumb Navigation
560
- * - Navigates directly to the selected step.
561
- * - Records timing data for the current step.
562
- */
563
- const handleTimelineClick = (i) => gotoStep(i);
564
- /**
565
- * Resume Handlers
566
- * - `handleResume` jumps to the persisted step (clamped to current step
567
- * range) so a returning user picks up where they left off.
568
- * - `dismissResume` discards the saved marker so the banner does not appear
569
- * again for this process.
570
- */
571
- const handleResume = () => {
572
- if (resumableStep == null || !steps.length) {
573
- return;
574
- }
575
- const target = Math.max(0, Math.min(resumableStep, steps.length - 1));
576
- setResumableStep(null);
577
- if (target !== activeStep) {
578
- gotoStep(target);
579
- }
580
- };
581
- const dismissResume = () => {
582
- setResumableStep(null);
583
- try {
584
- clearProgressEntry(activeStepStorageKey);
585
- } catch (error) {
586
- console.warn('Unable to clear resume marker from local storage.', error);
587
- }
588
- };
589
- /**
590
- * Process Completion
591
- * - Records final step timing.
592
- * - Submits process completion data.
593
- * - Navigates back on successful completion.
594
- */
595
- const handleFinish = async () => {
596
- const final = recordStepTime();
597
- const success = await handleProcessSubmit(final);
598
- if (success && !nextProcessId) props.history?.goBack();
599
- return success;
600
- };
601
- /**
602
- * Start Next Process
603
- * - Records final timing of the current process.
604
- * - Submits current process data.
605
- * - Loads and initializes the next linked process.
606
- */
607
- const handleStartNextProcess = async () => {
608
- const final = recordStepTime();
609
- if (await handleProcessSubmit(final)) {
610
- await loadProcess(nextProcessId.next_process_id);
611
- setCurrentProcessId(nextProcessId.next_process_id);
612
- setActiveStep(0);
613
- setShowExternalWindow(true);
614
- }
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
- };
633
-
634
- function clearNarrationAudio() {
635
- if (narrationAudioRef.current) {
636
- narrationAudioRef.current.pause();
637
- narrationAudioRef.current.src = '';
638
- narrationAudioRef.current = null;
639
- }
640
-
641
- if (narrationAudioUrlRef.current && typeof window !== 'undefined' && window.URL) {
642
- window.URL.revokeObjectURL(narrationAudioUrlRef.current);
643
- narrationAudioUrlRef.current = null;
644
- }
645
- }
646
-
647
- function stopNarration() {
648
- clearNarrationAudio();
649
-
650
- if (typeof window !== 'undefined' && window.speechSynthesis) {
651
- window.speechSynthesis.cancel();
652
- }
653
-
654
- narrationUtteranceRef.current = null;
655
- }
656
-
657
- function buildRealtimeInstructions() {
658
- const step = steps[activeStep];
659
- const stepName = step?.step_name || `Step ${activeStep + 1}`;
660
- const stepDescription = step?.step_description || 'No additional description.';
661
-
662
- return [
663
- 'You are a warm, concise healthcare concierge assisting a guest during a guided process.',
664
- `Current step: ${stepName}.`,
665
- `Step description: ${stepDescription}.`,
666
- 'Answer in short, helpful sentences and keep the guest calm and informed.',
667
- 'Avoid medical diagnosis or treatment advice.',
668
- ].join(' ');
669
- }
670
-
671
- async function startRealtimeConversation() {
672
- if (realtimeSessionRef.current) {
673
- return;
674
- }
675
-
676
- const session = createOpenAIRealtimeSession({
677
- instructions: buildRealtimeInstructions(),
678
- onStatus: (status) => {
679
- setRealtimeStatus(status);
680
- },
681
- onError: (error) => {
682
- console.error('OpenAI Realtime error:', error);
683
- message.error(error?.message || 'OpenAI Realtime connection failed.');
684
- },
685
- });
686
-
687
- realtimeSessionRef.current = session;
688
- try {
689
- await session.connect();
690
- } catch (error) {
691
- realtimeSessionRef.current = null;
692
- }
693
- }
694
-
695
- function stopRealtimeConversation() {
696
- if (realtimeSessionRef.current) {
697
- realtimeSessionRef.current.disconnect();
698
- realtimeSessionRef.current = null;
699
- }
700
- setRealtimeStatus('idle');
701
- }
702
-
703
- function playAudioBlob(audioBlob) {
704
- return new Promise((resolve, reject) => {
705
- if (typeof window === 'undefined' || !window.Audio || !window.URL) {
706
- reject(new Error('Audio playback is not available.'));
707
- return;
708
- }
709
-
710
- const audioUrl = window.URL.createObjectURL(audioBlob);
711
- const audio = new window.Audio(audioUrl);
712
-
713
- narrationAudioRef.current = audio;
714
- narrationAudioUrlRef.current = audioUrl;
715
-
716
- const cleanup = () => {
717
- if (narrationAudioRef.current === audio) {
718
- narrationAudioRef.current = null;
719
- }
720
-
721
- if (narrationAudioUrlRef.current === audioUrl) {
722
- window.URL.revokeObjectURL(audioUrl);
723
- narrationAudioUrlRef.current = null;
724
- }
725
- };
726
-
727
- audio.onended = () => {
728
- cleanup();
729
- resolve();
730
- };
731
-
732
- audio.onpause = () => {
733
- cleanup();
734
- resolve();
735
- };
736
-
737
- audio.onerror = () => {
738
- cleanup();
739
- reject(new Error('Audio playback failed.'));
740
- };
741
-
742
- audio.play().catch((error) => {
743
- cleanup();
744
- reject(error);
745
- });
746
- });
747
- }
748
-
749
- function speakWithBrowser(text) {
750
- return new Promise((resolve, reject) => {
751
- if (typeof window === 'undefined' || !window.speechSynthesis || !window.SpeechSynthesisUtterance) {
752
- reject(new Error('Speech synthesis is not available.'));
753
- return;
754
- }
755
-
756
- const utterance = new window.SpeechSynthesisUtterance(text);
757
- utterance.lang = process.env.REACT_APP_STEP_TTS_LANG || 'en-US';
758
-
759
- const rate = Number(process.env.REACT_APP_STEP_TTS_RATE || 1);
760
- const pitch = Number(process.env.REACT_APP_STEP_TTS_PITCH || 1);
761
-
762
- utterance.rate = Number.isFinite(rate) ? rate : 1;
763
- utterance.pitch = Number.isFinite(pitch) ? pitch : 1;
764
-
765
- const selectedBrowserVoice = voiceSelections.browser;
766
- if (selectedBrowserVoice) {
767
- const browserVoice = window.speechSynthesis.getVoices().find((voice) => (voice.voiceURI || voice.name) === selectedBrowserVoice);
768
-
769
- if (browserVoice) {
770
- utterance.voice = browserVoice;
771
- }
772
- }
773
-
774
- utterance.onend = () => {
775
- if (narrationUtteranceRef.current === utterance) {
776
- narrationUtteranceRef.current = null;
777
- }
778
- resolve();
779
- };
780
-
781
- utterance.onerror = () => {
782
- if (narrationUtteranceRef.current === utterance) {
783
- narrationUtteranceRef.current = null;
784
- }
785
- reject(new Error('Browser narration failed.'));
786
- };
787
-
788
- narrationUtteranceRef.current = utterance;
789
- window.speechSynthesis.speak(utterance);
790
- });
791
- }
792
-
793
- async function synthesizeGeminiAudio(text) {
794
- const apiKey = getGeminiApiKey();
795
-
796
- if (!apiKey) {
797
- throw new Error('Gemini API key is missing.');
798
- }
799
-
800
- const selectedVoiceName = voiceSelections.gemini || DEFAULT_GEMINI_TTS_VOICE;
801
- const endpoint = `${GEMINI_TTS_API_BASE_URL}/models/${GEMINI_TTS_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`;
802
- const response = await fetch(endpoint, {
803
- method: 'POST',
804
- headers: {
805
- 'Content-Type': 'application/json',
806
- },
807
- body: JSON.stringify({
808
- contents: [
809
- {
810
- role: 'user',
811
- parts: [{ text }],
812
- },
813
- ],
814
- generationConfig: {
815
- responseModalities: ['AUDIO'],
816
- speechConfig: {
817
- voiceConfig: {
818
- prebuiltVoiceConfig: {
819
- voiceName: selectedVoiceName,
820
- },
821
- },
822
- },
823
- },
824
- }),
825
- });
826
-
827
- if (!response.ok) {
828
- throw new Error(`Gemini TTS request failed with status ${response.status}.`);
829
- }
830
-
831
- const payload = await response.json();
832
- const audio = extractGeminiAudio(payload);
833
-
834
- if (!audio || !audio.data) {
835
- throw new Error('Gemini did not return audio data.');
836
- }
837
-
838
- return base64AudioToBlob(audio.data, audio.mimeType || 'audio/wav');
839
- }
840
-
841
- async function synthesizeOpenAIAudio(text) {
842
- const apiKey = getOpenAIApiKey();
843
-
844
- if (!apiKey) {
845
- throw new Error('OpenAI API key is missing.');
846
- }
847
-
848
- const selectedVoice = voiceSelections.openai || DEFAULT_OPENAI_TTS_VOICE;
849
- const response = await fetch(OPENAI_TTS_ENDPOINT, {
850
- method: 'POST',
851
- headers: {
852
- 'Content-Type': 'application/json',
853
- Authorization: `Bearer ${apiKey}`,
854
- },
855
- body: JSON.stringify({
856
- model: OPENAI_TTS_MODEL,
857
- voice: selectedVoice,
858
- input: text,
859
- response_format: OPENAI_TTS_FORMAT,
860
- }),
861
- });
862
-
863
- if (!response.ok) {
864
- throw new Error(`OpenAI TTS request failed with status ${response.status}.`);
865
- }
866
-
867
- return response.blob();
868
- }
869
-
870
- async function synthesizeElevenLabsAudio(text) {
871
- const apiKey = getElevenLabsApiKey();
872
-
873
- if (!apiKey) {
874
- throw new Error('ElevenLabs API key is missing.');
875
- }
876
-
877
- const selectedVoiceId = voiceSelections.elevenlabs || DEFAULT_ELEVENLABS_VOICE_ID;
878
- const endpoint = `${ELEVENLABS_TTS_API_BASE_URL}/${encodeURIComponent(selectedVoiceId)}/stream?output_format=${encodeURIComponent(
879
- ELEVENLABS_OUTPUT_FORMAT
880
- )}`;
881
- const response = await fetch(endpoint, {
882
- method: 'POST',
883
- headers: {
884
- 'Content-Type': 'application/json',
885
- Accept: 'audio/mpeg',
886
- 'xi-api-key': apiKey,
887
- },
888
- body: JSON.stringify({
889
- text,
890
- model_id: ELEVENLABS_MODEL_ID,
891
- }),
892
- });
893
-
894
- if (!response.ok) {
895
- throw new Error(`ElevenLabs TTS request failed with status ${response.status}.`);
896
- }
897
-
898
- return response.blob();
899
- }
900
-
901
- async function synthesizeSarvamAudio(text) {
902
- const apiKey = getSarvamApiKey();
903
-
904
- if (!apiKey) {
905
- throw new Error('Sarvam API key is missing.');
906
- }
907
-
908
- const selectedSpeaker = voiceSelections.sarvam || SARVAM_VOICE_OPTIONS[0].value;
909
- const response = await fetch(SARVAM_TTS_ENDPOINT, {
910
- method: 'POST',
911
- headers: {
912
- 'Content-Type': 'application/json',
913
- 'api-subscription-key': apiKey,
914
- },
915
- body: JSON.stringify({
916
- text,
917
- target_language_code: SARVAM_TARGET_LANGUAGE_CODE,
918
- model: SARVAM_TTS_MODEL,
919
- speaker: selectedSpeaker,
920
- output_audio_codec: SARVAM_OUTPUT_AUDIO_CODEC,
921
- }),
922
- });
923
-
924
- const payload = await response.json().catch(() => null);
925
-
926
- if (!response.ok) {
927
- throw new Error(`Sarvam TTS request failed with status ${response.status}.`);
928
- }
929
-
930
- const audioBase64 = payload?.audios?.[0];
931
- if (!audioBase64) {
932
- throw new Error('Sarvam did not return any audio data.');
933
- }
934
-
935
- const codec = (SARVAM_OUTPUT_AUDIO_CODEC || '').toLowerCase();
936
- const mimeType = codec === 'mp3' ? 'audio/mpeg' : 'audio/wav';
937
-
938
- return base64AudioToBlob(audioBase64, mimeType);
939
- }
940
-
941
- async function speakText(text) {
942
- if (!text || typeof window === 'undefined') {
943
- return;
944
- }
945
-
946
- stopNarration();
947
-
948
- if (voiceProvider === 'gemini') {
949
- const geminiAudio = await synthesizeGeminiAudio(text);
950
- await playAudioBlob(geminiAudio);
951
- return;
952
- }
953
-
954
- if (voiceProvider === 'elevenlabs') {
955
- const elevenLabsAudio = await synthesizeElevenLabsAudio(text);
956
- await playAudioBlob(elevenLabsAudio);
957
- return;
958
- }
959
-
960
- if (voiceProvider === 'openai') {
961
- const openAiAudio = await synthesizeOpenAIAudio(text);
962
- await playAudioBlob(openAiAudio);
963
- return;
964
- }
965
-
966
- if (voiceProvider === 'sarvam') {
967
- const sarvamAudio = await synthesizeSarvamAudio(text);
968
- await playAudioBlob(sarvamAudio);
969
- return;
970
- }
971
-
972
- throw new Error('Browser narration is disabled. Use Gemini, ElevenLabs, or OpenAI.');
973
- }
974
-
975
- async function speakCurrentStep() {
976
- const step = steps[activeStep];
977
- const guide = buildGuestStepGuide(step, activeStep, steps.length);
978
-
979
- try {
980
- await speakText(guide.narration);
981
- } catch (error) {
982
- if (!narrationFallbackNoticeRef.current) {
983
- const providerLabel =
984
- voiceProvider === 'gemini'
985
- ? 'Gemini'
986
- : voiceProvider === 'elevenlabs'
987
- ? 'ElevenLabs'
988
- : voiceProvider === 'openai'
989
- ? 'OpenAI'
990
- : 'Selected provider';
991
- message.warning(`${providerLabel} narration failed.`);
992
- narrationFallbackNoticeRef.current = true;
993
- }
994
-
995
- message.error(error?.message || 'Unable to play narration for this step.');
996
- }
997
- }
998
-
999
- async function toggleStepFullscreen() {
1000
- if (typeof document === 'undefined') {
1001
- return;
1002
- }
1003
-
1004
- const targetElement = fullscreenViewportRef.current;
1005
-
1006
- if (!targetElement || !targetElement.requestFullscreen) {
1007
- return;
1008
- }
1009
-
1010
- try {
1011
- if (document.fullscreenElement === targetElement) {
1012
- await document.exitFullscreen();
1013
- return;
1014
- }
1015
-
1016
- if (document.fullscreenElement) {
1017
- await document.exitFullscreen();
1018
- }
1019
-
1020
- await targetElement.requestFullscreen();
1021
- } catch (error) {
1022
- console.error('Failed to toggle step fullscreen mode:', error);
1023
- }
1024
- }
1025
- /**
1026
- * Dynamic Step Renderer
1027
- * - Resolves and renders step-specific components dynamically.
1028
- * - Passes configuration, parameters, and handlers to the component.
1029
- * - Handles missing steps or components gracefully.
1030
- */
1031
- /**
1032
- * Render the active step's dynamic component.
1033
- *
1034
- * Intentionally a plain function (not a component) called inline as
1035
- * `{renderDynamicStep()}`. Defining it as a component inside the parent's
1036
- * render body creates a fresh component *type* on every re-render — React
1037
- * then unmounts and remounts the step component on every parent state
1038
- * change (touchNavVisible, stepSlideDirection, activeStep timer, etc.),
1039
- * which caused visible re-render/jitter during swipe navigation. Evaluating
1040
- * it as a function just yields JSX for the real step Component, whose type
1041
- * is stable, so React reconciles in place.
1042
- */
1043
- const renderDynamicStep = () => {
1044
- const step = steps[activeStep];
1045
- if (!step) return <Empty description="No step selected" />;
1046
-
1047
- const Component = allComponents[step.related_page];
1048
- if (!Component) return <Empty description={`Component "${step.related_page}" not found`} />;
1049
-
1050
- return <Component {...step.config} {...props} step={step} params={urlParams} onStepComplete={() => setIsStepCompleted(true)} />;
1051
- };
1052
-
1053
- useEffect(() => {
1054
- const handleKeyDown = (event) => {
1055
- if (event.key === 'ArrowLeft' && activeStep > 0) {
1056
- handlePrevious();
1057
- }
1058
-
1059
- if (event.key === 'ArrowRight' && activeStep < steps.length - 1) {
1060
- handleNext();
1061
- }
1062
- };
1063
-
1064
- // main window (document!)
1065
- document.addEventListener('keydown', handleKeyDown);
1066
-
1067
- // external window (document!)
1068
- if (externalWin && externalWin.document) {
1069
- externalWin.document.addEventListener('keydown', handleKeyDown);
1070
- }
1071
-
1072
- return () => {
1073
- document.removeEventListener('keydown', handleKeyDown);
1074
-
1075
- if (externalWin && externalWin.document) {
1076
- externalWin.document.removeEventListener('keydown', handleKeyDown);
1077
- }
1078
- };
1079
- }, [activeStep, steps, externalWin]);
1080
-
1081
- /**
1082
- * Touch-device detection.
1083
- * - Runs once on mount.
1084
- * - Uses `matchMedia('(pointer: coarse)')` as the primary signal because it
1085
- * targets the actual input hardware (covers touch laptops correctly) and
1086
- * falls back to `ontouchstart` / `navigator.maxTouchPoints` for older
1087
- * browsers.
1088
- * - When neither signal matches, the effect bails out and `isTouchDevice`
1089
- * stays false so desktop renders without any touch-only UI.
1090
- */
1091
- useEffect(() => {
1092
- if (typeof window === 'undefined') {
1093
- return undefined;
1094
- }
1095
-
1096
- const hasCoarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
1097
- const hasTouch = 'ontouchstart' in window || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
1098
-
1099
- if (!hasCoarsePointer && !hasTouch) {
1100
- return undefined;
1101
- }
1102
-
1103
- setIsTouchDevice(true);
1104
- }, []);
1105
-
1106
- /**
1107
- * Show the floating prev/next arrow buttons and reset their auto-hide timer.
1108
- * - Any pending hide timeout is cleared so a rapid sequence of touches keeps
1109
- * the arrows on-screen continuously instead of flickering.
1110
- * - A fresh timeout is scheduled for TOUCH_NAV_HIDE_DELAY so the arrows fade
1111
- * away once the user stops interacting, keeping the step content clear.
1112
- */
1113
- const revealTouchNav = () => {
1114
- if (typeof window === 'undefined') {
1115
- return;
1116
- }
1117
-
1118
- setTouchNavVisible(true);
1119
-
1120
- if (touchNavHideTimeoutRef.current) {
1121
- window.clearTimeout(touchNavHideTimeoutRef.current);
1122
- }
1123
-
1124
- touchNavHideTimeoutRef.current = window.setTimeout(() => {
1125
- setTouchNavVisible(false);
1126
- touchNavHideTimeoutRef.current = null;
1127
- }, TOUCH_NAV_HIDE_DELAY);
1128
- };
1129
-
1130
- /**
1131
- * onTouchStart for the stage body.
1132
- * - Records the initial touch position so handleStageTouchEnd can measure
1133
- * the swipe delta.
1134
- * - Also reveals the side arrows immediately, giving the user a visible
1135
- * navigation affordance as soon as they touch the screen.
1136
- */
1137
- const handleStageTouchStart = (event) => {
1138
- if (!isTouchDevice || !event.touches || !event.touches.length) {
1139
- return;
1140
- }
1141
-
1142
- const touch = event.touches[0];
1143
- touchStartRef.current = { x: touch.clientX, y: touch.clientY };
1144
- revealTouchNav();
1145
- };
1146
-
1147
- /**
1148
- * onTouchEnd for the stage body.
1149
- * - Computes the horizontal/vertical delta against the stored touch origin.
1150
- * - Ignores gestures that are vertical-dominant or below the distance
1151
- * threshold, so normal scrolling and short taps are not hijacked.
1152
- * - A left swipe advances to the next step (subject to the same
1153
- * `isStepCompleted` / final-step rules as the visible Next button); a
1154
- * right swipe goes back. Each successful swipe re-reveals the arrows so
1155
- * the user can continue tapping if they prefer.
1156
- */
1157
- const handleStageTouchEnd = (event) => {
1158
- const start = touchStartRef.current;
1159
- touchStartRef.current = null;
1160
-
1161
- if (!start || !event.changedTouches || !event.changedTouches.length) {
1162
- return;
1163
- }
1164
-
1165
- const touch = event.changedTouches[0];
1166
- const deltaX = touch.clientX - start.x;
1167
- const deltaY = touch.clientY - start.y;
1168
-
1169
- if (Math.abs(deltaY) > Math.abs(deltaX)) {
1170
- return;
1171
- }
1172
- if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD) {
1173
- return;
1174
- }
1175
- if (Math.abs(deltaY) > SWIPE_VERTICAL_TOLERANCE) {
1176
- return;
1177
- }
1178
-
1179
- if (deltaX < 0) {
1180
- const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1181
- if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1182
- handleNext();
1183
- revealTouchNav();
1184
- }
1185
- } else if (activeStep > 0) {
1186
- handlePrevious();
1187
- revealTouchNav();
1188
- }
1189
- };
1190
-
1191
- /**
1192
- * Cleanup any pending auto-hide timeout on unmount so the callback cannot
1193
- * fire against a stale component and emit a React warning.
1194
- */
1195
- useEffect(() => {
1196
- return () => {
1197
- if (typeof window !== 'undefined' && touchNavHideTimeoutRef.current) {
1198
- window.clearTimeout(touchNavHideTimeoutRef.current);
1199
- }
1200
- };
1201
- }, []);
1202
-
1203
- useEffect(() => {
1204
- if (typeof document === 'undefined') {
1205
- return undefined;
1206
- }
1207
-
1208
- const handleFullscreenChange = () => {
1209
- setIsStepFullscreen(document.fullscreenElement === fullscreenViewportRef.current);
1210
- };
1211
-
1212
- document.addEventListener('fullscreenchange', handleFullscreenChange);
1213
- handleFullscreenChange();
1214
-
1215
- return () => {
1216
- document.removeEventListener('fullscreenchange', handleFullscreenChange);
1217
- };
1218
- }, []);
1219
-
1220
- useEffect(() => {
1221
- if (!NARRATION_CONTROLS_ENABLED || !autoNarration) {
1222
- return;
1223
- }
1224
-
1225
- if (loading || !steps.length || !steps[activeStep]) {
1226
- return;
1227
- }
1228
-
1229
- speakCurrentStep();
1230
- }, [
1231
- activeStep,
1232
- steps,
1233
- loading,
1234
- autoNarration,
1235
- voiceProvider,
1236
- voiceSelections.browser,
1237
- voiceSelections.elevenlabs,
1238
- voiceSelections.openai,
1239
- voiceSelections.sarvam,
1240
- ]);
1241
-
1242
- useEffect(() => {
1243
- const session = realtimeSessionRef.current;
1244
- if (!session || session.status !== 'connected') {
1245
- return;
1246
- }
1247
-
1248
- session.sendEvent({
1249
- type: 'session.update',
1250
- session: {
1251
- instructions: buildRealtimeInstructions(),
1252
- },
1253
- });
1254
- }, [activeStep, steps]);
1255
-
1256
- useEffect(() => {
1257
- return () => {
1258
- stopNarration();
1259
- stopRealtimeConversation();
1260
- };
1261
- }, []);
1262
-
1263
- /**
1264
- * Renders the main process UI including breadcrumb, step details,
1265
- * and action buttons. This content is reused in both normal view
1266
- * and external window view.
1267
- */
1268
- const renderContent = () => {
1269
- const currentStep = steps[activeStep];
1270
- const isFinalStep = currentStep?.order_seqtype === 'E';
1271
- const currentVoiceOptions =
1272
- voiceProvider === 'gemini'
1273
- ? GEMINI_VOICE_OPTIONS
1274
- : voiceProvider === 'elevenlabs'
1275
- ? ELEVENLABS_VOICE_OPTIONS
1276
- : voiceProvider === 'openai'
1277
- ? OPENAI_TTS_VOICE_OPTIONS
1278
- : SARVAM_VOICE_OPTIONS;
1279
- const currentVoiceValue = voiceSelections[voiceProvider] || undefined;
1280
- const openAiTokenEndpoint = process.env.OPENAI_REALTIME_TOKEN_ENDPOINT || process.env.REACT_APP_OPENAI_REALTIME_TOKEN_ENDPOINT;
1281
- const canStartRealtime = hasOpenAIRealtimeCredentials(openAiTokenEndpoint);
1282
-
1283
- return (
1284
- <div className="process-steps-page">
1285
- <div ref={fullscreenViewportRef} className="steps-viewport">
1286
- <Card className="steps-main-card">
1287
- {/* {activeStep > 0 && GuestInfoComponent && (
1288
- <div className="steps-patient-bar">
1289
- <GuestInfoComponent params={urlParams} />
1290
- </div>
1291
- )} */}
1292
-
1293
- <div className="steps-top-bar">
1294
- <div className="steps-breadcrumb-strip">
1295
- {steps.length ? (
1296
- steps.map((stepItem, stepIndex) => {
1297
- const isActiveBreadcrumb = stepIndex === activeStep;
1298
- const isCompletedBreadcrumb = stepIndex < activeStep;
1299
-
1300
- return (
1301
- <button
1302
- key={stepItem.step_id || `${stepItem.step_name || 'step'}_${stepIndex}`}
1303
- type="button"
1304
- className={`steps-breadcrumb-item${isActiveBreadcrumb ? ' active' : ''}${isCompletedBreadcrumb ? ' completed' : ''}`}
1305
- onClick={() => handleTimelineClick(stepIndex)}
1306
- >
1307
- <span className="steps-breadcrumb-index">{stepIndex + 1}</span>
1308
- <span className="steps-breadcrumb-label">{stepItem.step_name || `Step ${stepIndex + 1}`}</span>
1309
- </button>
1310
- );
1311
- })
1312
- ) : (
1313
- <span className="steps-breadcrumb-empty">No steps loaded</span>
1314
- )}
1315
- </div>
1316
-
1317
- <div className="steps-nav-actions">
1318
- <Button type="dashed" icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
1319
- {isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1320
- </Button>
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
-
1335
- {activeStep > 0 && (
1336
- <Button type="default" icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
1337
- Back
1338
- </Button>
1339
- )}
1340
-
1341
- {/* {activeStep > 0 && !isFinalStep && (
1342
- <Button type="default" onClick={handleSkip}>
1343
- Skip
1344
- </Button>
1345
- )} */}
1346
-
1347
- {isFinalStep ? (
1348
- <>
1349
- {!showNextProcessAction && (
1350
- <Button
1351
- type="primary"
1352
- onClick={async () => {
1353
- const success = await handleFinish();
1354
- if (success && nextProcessId?.next_process_id) {
1355
- setShowNextProcessAction(true);
1356
- }
1357
- }}
1358
- >
1359
- Finish
1360
- </Button>
1361
- )}
1362
- {showNextProcessAction && nextProcessId?.next_process_id && (
1363
- <Button type="primary" onClick={handleStartNextProcess}>
1364
- Start {nextProcessId.next_process_name} <ArrowRightOutlined />
1365
- </Button>
1366
- )}
1367
- </>
1368
- ) : (
1369
- <Button type="primary" disabled={!isStepCompleted} onClick={handleNext}>
1370
- {/*
1371
- First-step label is resolved via FIRST_STEP_LABELS using
1372
- the process name (lowercased + trimmed) as the key. Known
1373
- processes get a tailored CTA (e.g. "Verify Profile",
1374
- "Start Consultation"); unknown processes fall back to the
1375
- generic "Next" label. All non-first steps always render
1376
- "Next".
1377
- */}
1378
- {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'} <ArrowRightOutlined />
1379
- </Button>
1380
- )}
1381
- </div>
1382
- </div>
1383
-
1384
- {/*
1385
- Resume banner.
1386
- - Renders only when a saved active step is ahead of the current
1387
- position, so it stays out of the way during normal navigation
1388
- and only surfaces when the user returns after an unexpected
1389
- exit (refresh, tab close, navigation away).
1390
- - Resuming clamps to the current step range; dismissing clears
1391
- the persisted marker so the banner does not return for this
1392
- process.
1393
- */}
1394
- {resumableStep != null && resumableStep > activeStep && resumableStep < steps.length ? (
1395
- <div className="steps-resume-banner" role="status">
1396
- <span className="steps-resume-banner-text">
1397
- You left at Step {resumableStep + 1}
1398
- {steps[resumableStep]?.step_name ? ` — ${steps[resumableStep].step_name}` : ''}.
1399
- </span>
1400
- <div className="steps-resume-banner-actions">
1401
- <Button type="primary" size="small" onClick={handleResume}>
1402
- Resume
1403
- </Button>
1404
- <Button type="text" size="small" onClick={dismissResume}>
1405
- Dismiss
1406
- </Button>
1407
- </div>
1408
- </div>
1409
- ) : null}
1410
-
1411
- <div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1412
- {/*
1413
- Stage body:
1414
- - `is-swipe-enabled` applies `touch-action: pan-y` so horizontal
1415
- gestures reach our handlers while vertical scrolling remains
1416
- native.
1417
- - Touch handlers are only attached on touch devices to keep
1418
- desktop event trees untouched.
1419
- */}
1420
- <div
1421
- className={`steps-stage-body${isTouchDevice ? ' is-swipe-enabled' : ''}`}
1422
- onTouchStart={isTouchDevice ? handleStageTouchStart : undefined}
1423
- onTouchEnd={isTouchDevice ? handleStageTouchEnd : undefined}
1424
- >
1425
- {/*
1426
- Floating prev/next arrow buttons.
1427
- - Rendered only on touch devices; `is-visible` class drives
1428
- the fade-in/out via CSS transitions.
1429
- - Disabled states mirror the visible Next/Back buttons in the
1430
- top bar: previous disabled on the first step; next disabled
1431
- on the last/final step or when the current step still
1432
- requires user completion (isStepCompleted === false).
1433
- - Clicking either button reveals the arrows again so the
1434
- auto-hide timer restarts after every interaction.
1435
- */}
1436
- {isTouchDevice ? (
1437
- <>
1438
- <button
1439
- type="button"
1440
- className={`steps-touch-nav steps-touch-nav-left${touchNavVisible ? ' is-visible' : ''}`}
1441
- aria-label="Previous step"
1442
- disabled={activeStep === 0}
1443
- onClick={() => {
1444
- revealTouchNav();
1445
- if (activeStep > 0) handlePrevious();
1446
- }}
1447
- >
1448
- <ArrowLeftOutlined />
1449
- </button>
1450
- <button
1451
- type="button"
1452
- className={`steps-touch-nav steps-touch-nav-right${touchNavVisible ? ' is-visible' : ''}`}
1453
- aria-label="Next step"
1454
- disabled={activeStep >= steps.length - 1 || steps[activeStep]?.order_seqtype === 'E' || !isStepCompleted}
1455
- onClick={() => {
1456
- revealTouchNav();
1457
- const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1458
- if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1459
- handleNext();
1460
- }
1461
- }}
1462
- >
1463
- <ArrowRightOutlined />
1464
- </button>
1465
- </>
1466
- ) : null}
1467
- <div
1468
- key={`${currentProcessId}_${activeStep}`}
1469
- className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
1470
- >
1471
- {/* <div className="steps-chat-step-top">
1472
- <span className="steps-index-pill">
1473
- Step {Math.min(activeStep + 1, steps.length || 1)} of {steps.length || 1}
1474
- </span>
1475
- <h2 className="steps-title">{currentStep?.step_name || 'No step selected'}</h2>
1476
- {currentStep?.step_description ? <p className="steps-description">{currentStep.step_description}</p> : null}
1477
- </div> */}
1478
-
1479
- <div className="steps-chat-step-component">
1480
- {loading ? (
1481
- <div className="steps-chat-loading">
1482
- <Spin />
1483
- </div>
1484
- ) : null}
1485
- {!loading ? renderDynamicStep() : null}
1486
- </div>
1487
- </div>
1488
- </div>
1489
- </div>
1490
-
1491
- {NARRATION_CONTROLS_ENABLED ? (
1492
- <div className="steps-bottom-nav steps-narration-bar">
1493
- <Select
1494
- className="steps-voice-provider-select"
1495
- value={voiceProvider}
1496
- options={VOICE_PROVIDER_OPTIONS}
1497
- onChange={(value) => setVoiceProvider(value)}
1498
- />
1499
- <Select
1500
- className="steps-voice-select"
1501
- value={currentVoiceValue}
1502
- options={currentVoiceOptions}
1503
- onChange={(value) =>
1504
- setVoiceSelections((oldSelections) => ({
1505
- ...oldSelections,
1506
- [voiceProvider]: value,
1507
- }))
1508
- }
1509
- placeholder="Select Voice"
1510
- optionFilterProp="label"
1511
- showSearch
1512
- disabled={!currentVoiceOptions.length}
1513
- />
1514
- <Button icon={<SoundOutlined />} onClick={speakCurrentStep} disabled={!currentStep}>
1515
- Read Step
1516
- </Button>
1517
- <Button
1518
- type={realtimeStatus === 'connected' ? 'default' : 'primary'}
1519
- disabled={!canStartRealtime}
1520
- onClick={() => {
1521
- if (realtimeStatus === 'connected' || realtimeStatus === 'connecting') {
1522
- stopRealtimeConversation();
1523
- return;
1524
- }
1525
- startRealtimeConversation();
1526
- }}
1527
- >
1528
- {realtimeStatus === 'connected' ? 'Stop Conversation' : realtimeStatus === 'connecting' ? 'Connecting...' : 'Start Conversation'}
1529
- </Button>
1530
- <Button onClick={() => setAutoNarration((oldValue) => !oldValue)}>Auto Narration: {autoNarration ? 'On' : 'Off'}</Button>
1531
- </div>
1532
- ) : null}
1533
- </Card>
1534
- </div>
1535
- </div>
1536
- );
1537
- };
1538
- /**
1539
- * Renders content in both the main window and an external window
1540
- * when external window mode is enabled.
1541
- */
1542
- if (showExternalWindow && props.showExternalWindow) {
1543
- return (
1544
- <>
1545
- <ExternalWindow
1546
- onWindowReady={(win) => {
1547
- setExternalWin(win);
1548
- win.focus();
1549
- }}
1550
- title={steps[activeStep]?.step_name || 'Process Step'}
1551
- onClose={() => setShowExternalWindow(false)}
1552
- // left={window.screenX + window.outerWidth}
1553
- // top={window.screenY}
1554
- width={props.ExternalWindowWidth || 1000}
1555
- height={props.ExternalWindowHeight || 1000}
1556
- >
1557
- {renderContent(false)}
1558
- </ExternalWindow>
1559
- {renderContent(true)}
1560
- </>
1561
- );
1562
- }
1563
- /**
1564
- * Default render when external window mode is disabled.
1565
- */
1566
- return renderContent();
1567
- }
1
+ /**
2
+ * ProcessStepsPage Component
3
+ *
4
+ * - Manages a multi-step, time-tracked process workflow.
5
+ * - Dynamically renders step-specific components based on configuration.
6
+ * - Tracks step and process durations with local persistence support.
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.
10
+ * - Handles process submission and optional chaining to the next process.
11
+ * - Renders a single active step view with compact breadcrumb controls.
12
+ */
13
+ import { ArrowLeftOutlined, ArrowRightOutlined, CompressOutlined, ExpandOutlined, SoundOutlined } from '@ant-design/icons';
14
+ import { Empty, Select, Spin, message } from 'antd';
15
+ import moment from 'moment';
16
+ import { useEffect, useRef, useState } from 'react';
17
+ import { ExternalWindow } from '../../components';
18
+ import { Dashboard } from '../../models';
19
+ import * as genericComponents from './../../lib';
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';
30
+ import { createOpenAIRealtimeSession, hasOpenAIRealtimeCredentials } from './openai-realtime';
31
+ import { clearProgressEntry, readProgressEntry, sweepStaleProgressKeys, writeProgressEntry } from './progress-storage';
32
+ import './steps.scss';
33
+
34
+ const TOUCH_NAV_HIDE_DELAY = 2800;
35
+ const SWIPE_DISTANCE_THRESHOLD = 60;
36
+ const SWIPE_VERTICAL_TOLERANCE = 80;
37
+
38
+ /**
39
+ * First-step CTA labels keyed by normalized process name.
40
+ * - Keys are the lowercased/trimmed process name returned by the backend.
41
+ * - Missing keys fall back to the generic 'Next' label at the call site.
42
+ * - Frozen so accidental mutation during render doesn't leak across renders.
43
+ */
44
+ const FIRST_STEP_LABELS = Object.freeze({
45
+ verification: 'Verify Profile',
46
+ consultation: 'Start Consultation',
47
+ });
48
+
49
+ const VOICE_PROVIDER_OPTIONS = [
50
+ { label: 'Gemini', value: 'gemini' },
51
+ { label: 'ElevenLabs', value: 'elevenlabs' },
52
+ { label: 'OpenAI', value: 'openai' },
53
+ ];
54
+
55
+ const GEMINI_VOICE_OPTIONS = [{ label: 'Kore', value: 'Kore' }];
56
+ const DEFAULT_GEMINI_TTS_VOICE = process.env.GEMINI_TTS_VOICE || process.env.REACT_APP_GEMINI_TTS_VOICE || GEMINI_VOICE_OPTIONS[0].value;
57
+ const OPENAI_TTS_VOICE_OPTIONS = [
58
+ { label: 'Alloy', value: 'alloy' },
59
+ { label: 'Ash', value: 'ash' },
60
+ { label: 'Coral', value: 'coral' },
61
+ { label: 'Echo', value: 'echo' },
62
+ { label: 'Fable', value: 'fable' },
63
+ { label: 'Nova', value: 'nova' },
64
+ { label: 'Onyx', value: 'onyx' },
65
+ { label: 'Sage', value: 'sage' },
66
+ { label: 'Shimmer', value: 'shimmer' },
67
+ ];
68
+ const DEFAULT_OPENAI_TTS_VOICE =
69
+ process.env.OPENAI_TTS_VOICE ||
70
+ process.env.REACT_APP_OPENAI_TTS_VOICE ||
71
+ process.env.OPENAI_REALTIME_VOICE ||
72
+ process.env.REACT_APP_OPENAI_REALTIME_VOICE ||
73
+ OPENAI_TTS_VOICE_OPTIONS[0].value;
74
+
75
+ const ELEVENLABS_VOICE_OPTIONS = [
76
+ { label: 'Rachel', value: '21m00Tcm4TlvDq8ikWAM' },
77
+ { label: 'Adam', value: 'pNInz6obpgDQGcFmaJgB' },
78
+ { label: 'Bella', value: 'EXAVITQu4vr4xnSDxMaL' },
79
+ { label: 'Antoni', value: 'ErXwobaYiN019PkySvjV' },
80
+ { label: 'Josh', value: 'TxGEqnHWrfWFTfGW9XjX' },
81
+ ];
82
+ const DEFAULT_ELEVENLABS_VOICE_ID =
83
+ process.env.ELEVENLABS_VOICE_ID ||
84
+ process.env.ELEVEN_LABS_VOICE_ID ||
85
+ process.env.REACT_APP_ELEVENLABS_VOICE_ID ||
86
+ ELEVENLABS_VOICE_OPTIONS[0].value;
87
+
88
+ const SARVAM_VOICE_OPTIONS = [
89
+ { label: 'Anushka', value: 'anushka' },
90
+ { label: 'Manisha', value: 'manisha' },
91
+ { label: 'Vidya', value: 'vidya' },
92
+ { label: 'Arya', value: 'arya' },
93
+ { label: 'Karun', value: 'karun' },
94
+ { label: 'Hitesh', value: 'hitesh' },
95
+ ];
96
+
97
+ const ELEVENLABS_TTS_API_BASE_URL =
98
+ process.env.ELEVENLABS_TTS_API_BASE_URL || process.env.REACT_APP_ELEVENLABS_TTS_API_BASE_URL || 'https://api.elevenlabs.io/v1/text-to-speech';
99
+ const ELEVENLABS_MODEL_ID = process.env.ELEVENLABS_MODEL_ID || process.env.REACT_APP_ELEVENLABS_MODEL_ID || 'eleven_multilingual_v2';
100
+ const ELEVENLABS_OUTPUT_FORMAT = process.env.ELEVENLABS_OUTPUT_FORMAT || process.env.REACT_APP_ELEVENLABS_OUTPUT_FORMAT || 'mp3_44100_128';
101
+ const GEMINI_TTS_MODEL = process.env.GEMINI_TTS_MODEL || process.env.REACT_APP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-preview-tts';
102
+ const GEMINI_TTS_API_BASE_URL =
103
+ process.env.GEMINI_API_BASE_URL || process.env.REACT_APP_GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
104
+ const OPENAI_TTS_ENDPOINT = process.env.OPENAI_TTS_ENDPOINT || process.env.REACT_APP_OPENAI_TTS_ENDPOINT || 'https://api.openai.com/v1/audio/speech';
105
+ const OPENAI_TTS_MODEL = process.env.OPENAI_TTS_MODEL || process.env.REACT_APP_OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
106
+ const OPENAI_TTS_FORMAT = process.env.OPENAI_TTS_FORMAT || process.env.REACT_APP_OPENAI_TTS_FORMAT || 'mp3';
107
+ const SARVAM_TTS_ENDPOINT = process.env.SARVAM_TTS_ENDPOINT || process.env.REACT_APP_SARVAM_TTS_ENDPOINT || 'https://api.sarvam.ai/text-to-speech';
108
+ const SARVAM_TTS_MODEL = process.env.SARVAM_TTS_MODEL || process.env.REACT_APP_SARVAM_TTS_MODEL || 'bulbul:v2';
109
+ const SARVAM_TARGET_LANGUAGE_CODE = process.env.SARVAM_TARGET_LANGUAGE_CODE || process.env.REACT_APP_SARVAM_TARGET_LANGUAGE_CODE || 'en-IN';
110
+ const SARVAM_OUTPUT_AUDIO_CODEC = process.env.SARVAM_OUTPUT_AUDIO_CODEC || process.env.REACT_APP_SARVAM_OUTPUT_AUDIO_CODEC || 'wav';
111
+ const NARRATION_CONTROLS_ENABLED = false;
112
+
113
+ export default function ProcessStepsPage({ match, CustomComponents = {}, ...props }) {
114
+ const allComponents = { ...genericComponents, ...CustomComponents };
115
+ const GuestInfoComponent = allComponents.EntryInfo;
116
+
117
+ const [loading, setLoading] = useState(false);
118
+ const [steps, setSteps] = useState([]);
119
+ const [processName, setProcessName] = useState(null);
120
+ const [activeStep, setActiveStep] = useState(0);
121
+ const [resumableStep, setResumableStep] = useState(null);
122
+ const [isStepCompleted, setIsStepCompleted] = useState(false);
123
+
124
+ const [nextProcessId, setNextProcessId] = useState(null);
125
+ const [previousProcessId, setPreviousProcessId] = useState(null);
126
+ const [stepStartTime, setStepStartTime] = useState(null);
127
+ const [processStartTime, setProcessStartTime] = useState(null);
128
+ const [processTimings, setProcessTimings] = useState([]);
129
+ const [showExternalWindow, setShowExternalWindow] = useState(false);
130
+ const [externalWin, setExternalWin] = useState(null);
131
+ const [autoNarration, setAutoNarration] = useState(NARRATION_CONTROLS_ENABLED);
132
+ const [voiceProvider, setVoiceProvider] = useState(
133
+ process.env.REACT_APP_STEP_TTS_PROVIDER && process.env.REACT_APP_STEP_TTS_PROVIDER !== 'browser'
134
+ ? process.env.REACT_APP_STEP_TTS_PROVIDER
135
+ : 'gemini'
136
+ );
137
+ const [browserVoiceOptions, setBrowserVoiceOptions] = useState([]);
138
+ const [voiceSelections, setVoiceSelections] = useState({
139
+ browser: process.env.REACT_APP_STEP_BROWSER_VOICE || process.env.REACT_APP_STEP_TTS_VOICE || '',
140
+ gemini: DEFAULT_GEMINI_TTS_VOICE,
141
+ elevenlabs: DEFAULT_ELEVENLABS_VOICE_ID,
142
+ openai: DEFAULT_OPENAI_TTS_VOICE,
143
+ sarvam: process.env.REACT_APP_SARVAM_SPEAKER || SARVAM_VOICE_OPTIONS[0].value,
144
+ });
145
+ const [stepSlideDirection, setStepSlideDirection] = useState('forward');
146
+ const [showNextProcessAction, setShowNextProcessAction] = useState(false);
147
+ const [isStepFullscreen, setIsStepFullscreen] = useState(false);
148
+ const [realtimeStatus, setRealtimeStatus] = useState('idle');
149
+ const [isTouchDevice, setIsTouchDevice] = useState(false);
150
+ const [touchNavVisible, setTouchNavVisible] = useState(false);
151
+
152
+ const narrationUtteranceRef = useRef(null);
153
+ const narrationAudioRef = useRef(null);
154
+ const narrationAudioUrlRef = useRef(null);
155
+ const narrationFallbackNoticeRef = useRef(false);
156
+ const realtimeSessionRef = useRef(null);
157
+ const fullscreenViewportRef = useRef(null);
158
+ const touchStartRef = useRef(null);
159
+ const touchNavHideTimeoutRef = useRef(null);
160
+
161
+ const urlParams = Location.search();
162
+ const isConsultationMode = String(urlParams?.consultation).toLowerCase() === 'true';
163
+ let processId = urlParams.processId;
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
+
196
+ // Load process details based on the current process ID
197
+ useEffect(() => {
198
+ sweepStaleProgressKeys();
199
+
200
+ loadProcess(currentProcessId);
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 : [];
212
+ setProcessTimings(savedTimings);
213
+
214
+ const savedStepEntry = readProgressEntry(activeStepStorageKey);
215
+ const parsedStep = savedStepEntry && savedStepEntry.guestRef === guestReference ? Number(savedStepEntry.step) : NaN;
216
+ const savedActiveStep = Number.isFinite(parsedStep) && parsedStep > 0 ? parsedStep : 0;
217
+ setResumableStep(savedActiveStep > 0 ? savedActiveStep : null);
218
+
219
+ setProcessStartTime(Date.now());
220
+ setStepStartTime(Date.now());
221
+ setShowNextProcessAction(false);
222
+ setActiveStep(0);
223
+ }, [currentProcessId, guestReference]);
224
+
225
+ /**
226
+ * The active step is persisted inline in `gotoStep` rather than via a
227
+ * useEffect — running it as an effect captured `activeStepStorageKey` from
228
+ * a closure that could go stale during URL changes, allowing one guest's
229
+ * progress to be written under another guest's key. Writing in `gotoStep`
230
+ * happens synchronously with the user action using the current render's
231
+ * key, so it can never target a different guest than the one interacting.
232
+ */
233
+
234
+ /**
235
+ * Sync the loaded process name into the address bar.
236
+ * - Mirrors `processName` into a `process` query parameter so deep-links and
237
+ * refreshes carry the human-readable process label.
238
+ * - Uses `window.history.replaceState` to avoid a navigation event, which
239
+ * keeps React Router state and component instances stable.
240
+ * - Removes the param when `processName` is null/empty so stale values do
241
+ * not linger after a process clears.
242
+ */
243
+ useEffect(() => {
244
+ if (typeof window === 'undefined') {
245
+ return;
246
+ }
247
+
248
+ const params = new URLSearchParams(window.location.search);
249
+ let changed = false;
250
+
251
+ const trimmedName = typeof processName === 'string' ? processName.trim() : '';
252
+ if (trimmedName) {
253
+ if (params.get('process') !== trimmedName) {
254
+ params.set('process', trimmedName);
255
+ changed = true;
256
+ }
257
+ } else if (params.has('process')) {
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;
282
+ }
283
+
284
+ const search = params.toString();
285
+ const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash || ''}`;
286
+ window.history.replaceState(window.history.state, '', newUrl);
287
+ }, [processName, currentProcessId]);
288
+
289
+ //// Reset step start time whenever the active step changes
290
+
291
+ useEffect(() => {
292
+ setStepStartTime(Date.now());
293
+ }, [activeStep]);
294
+
295
+ // Check whether the current step is completed or mandatory
296
+ useEffect(() => {
297
+ if (steps.length > 0) {
298
+ setIsStepCompleted(steps[activeStep]?.is_mandatory !== true);
299
+ }
300
+ }, [activeStep, steps]);
301
+
302
+ useEffect(() => {
303
+ if (steps[activeStep]?.order_seqtype !== 'E') {
304
+ setShowNextProcessAction(false);
305
+ }
306
+ }, [activeStep, steps]);
307
+
308
+ useEffect(() => {
309
+ if (typeof window === 'undefined' || !window.speechSynthesis) {
310
+ return undefined;
311
+ }
312
+
313
+ const updateBrowserVoices = () => {
314
+ const voices = window.speechSynthesis
315
+ .getVoices()
316
+ .map((voice) => ({
317
+ label: `${voice.name} (${voice.lang})`,
318
+ value: voice.voiceURI || voice.name,
319
+ }))
320
+ .sort((voiceA, voiceB) => voiceA.label.localeCompare(voiceB.label));
321
+
322
+ setBrowserVoiceOptions(voices);
323
+
324
+ if (voices.length) {
325
+ setVoiceSelections((oldSelections) => {
326
+ if (oldSelections.browser) {
327
+ return oldSelections;
328
+ }
329
+
330
+ return {
331
+ ...oldSelections,
332
+ browser: voices[0].value,
333
+ };
334
+ });
335
+ }
336
+ };
337
+
338
+ updateBrowserVoices();
339
+ if (typeof window.speechSynthesis.addEventListener === 'function') {
340
+ window.speechSynthesis.addEventListener('voiceschanged', updateBrowserVoices);
341
+ } else {
342
+ window.speechSynthesis.onvoiceschanged = updateBrowserVoices;
343
+ }
344
+
345
+ return () => {
346
+ if (typeof window.speechSynthesis.removeEventListener === 'function') {
347
+ window.speechSynthesis.removeEventListener('voiceschanged', updateBrowserVoices);
348
+ } else if (window.speechSynthesis.onvoiceschanged === updateBrowserVoices) {
349
+ window.speechSynthesis.onvoiceschanged = null;
350
+ }
351
+ };
352
+ }, []);
353
+
354
+ useEffect(() => {
355
+ narrationFallbackNoticeRef.current = false;
356
+ }, [voiceProvider]);
357
+
358
+ useEffect(() => {
359
+ const isSupportedProvider = VOICE_PROVIDER_OPTIONS.some((option) => option.value === voiceProvider);
360
+
361
+ if (!isSupportedProvider) {
362
+ setVoiceProvider('gemini');
363
+ }
364
+ }, [voiceProvider]);
365
+
366
+ useEffect(() => {
367
+ stopNarration();
368
+ }, [voiceProvider, voiceSelections.browser, voiceSelections.gemini, voiceSelections.elevenlabs, voiceSelections.openai, voiceSelections.sarvam]);
369
+
370
+ useEffect(() => {
371
+ const providerVoices =
372
+ voiceProvider === 'gemini'
373
+ ? GEMINI_VOICE_OPTIONS
374
+ : voiceProvider === 'elevenlabs'
375
+ ? ELEVENLABS_VOICE_OPTIONS
376
+ : voiceProvider === 'openai'
377
+ ? OPENAI_TTS_VOICE_OPTIONS
378
+ : SARVAM_VOICE_OPTIONS;
379
+
380
+ if (!providerVoices.length) {
381
+ return;
382
+ }
383
+
384
+ setVoiceSelections((oldSelections) => {
385
+ if (oldSelections[voiceProvider]) {
386
+ return oldSelections;
387
+ }
388
+
389
+ return {
390
+ ...oldSelections,
391
+ [voiceProvider]: providerVoices[0].value,
392
+ };
393
+ });
394
+ }, [voiceProvider, browserVoiceOptions]);
395
+
396
+ // Save updated process timings to state and localStorage
397
+ const saveTimings = (updated) => {
398
+ const safeTimings = Array.isArray(updated) ? updated : [];
399
+ setProcessTimings(safeTimings);
400
+ writeProgressEntry(timingsStorageKey, { guestRef: guestReference, timings: safeTimings });
401
+ };
402
+ // Record time spent on the current step
403
+
404
+ const recordStepTime = (status = 'completed') => {
405
+ // Exit if step start time or step data is missing
406
+
407
+ if (!stepStartTime || !steps[activeStep]) return processTimings;
408
+ // Capture end time and calculate duration
409
+
410
+ const endTime = Date.now();
411
+ const duration = endTime - stepStartTime;
412
+ const stepId = steps[activeStep].step_id;
413
+ // Clone existing timings
414
+
415
+ const previousTimings = Array.isArray(processTimings) ? processTimings : [];
416
+ const updated = [...previousTimings];
417
+ const index = updated.findIndex((t) => t.step_id === stepId);
418
+ // Create timing entry for the step
419
+
420
+ const entry = {
421
+ step_id: stepId,
422
+ start_time: moment(stepStartTime).format('DD-MM-YYYY HH:mm:ss'),
423
+ end_time: moment(endTime).format('DD-MM-YYYY HH:mm:ss'),
424
+ duration,
425
+ status,
426
+ };
427
+ // Update existing entry or add a new one
428
+ if (index > -1) {
429
+ updated[index] = { ...updated[index], ...entry, duration: updated[index].duration + duration };
430
+ } else {
431
+ updated.push(entry);
432
+ }
433
+
434
+ return updated;
435
+ };
436
+
437
+ /**
438
+ * @param {*} processId
439
+ *
440
+ * Process Loading
441
+ * - Fetches process details and step configuration using the process ID.
442
+ * - Manages loading state during the API call.
443
+ * - Stores step data and prepares next process details if available.
444
+ * - Handles API errors and maintains UI stability.
445
+ */
446
+ async function loadProcess(processId) {
447
+ setLoading(true);
448
+ setNextProcessId(null);
449
+ setPreviousProcessId(null);
450
+
451
+ try {
452
+ const result = await Dashboard.loadProcess(processId);
453
+
454
+ setSteps(result?.data?.steps || []);
455
+ setProcessName(result?.data?.process_name ?? null);
456
+ if (result?.data?.next_process_id) setNextProcessId(result.data);
457
+ if (result?.data?.previous_process_id) setPreviousProcessId(result.data);
458
+ } catch (e) {
459
+ console.error('Error loading process steps:', e);
460
+ } finally {
461
+ setLoading(false);
462
+ }
463
+ }
464
+ /**
465
+ * @param {*} finalTimings
466
+ *
467
+ * Process Submission
468
+ * - Builds payload with process metadata, reference details, and step timings.
469
+ * - Submits process completion data to the backend.
470
+ * - Clears stored timings on successful submission.
471
+ * - Persists timing data locally if submission fails.
472
+ */
473
+ const handleProcessSubmit = async (finalTimings) => {
474
+ const payload = {
475
+ process_id: currentProcessId,
476
+ status: 'completed',
477
+ reference_id: urlParams?.opb_id || urlParams?.reference_id,
478
+ reference_number: urlParams?.opno || urlParams?.reference_number,
479
+ mode: urlParams?.mode,
480
+ process: {
481
+ process_start_time: moment(processStartTime).format('DD-MM-YYYY HH:mm'),
482
+ process_end_time: moment().format('DD-MM-YYYY HH:mm'),
483
+ steps: finalTimings,
484
+ },
485
+ };
486
+
487
+ try {
488
+ const response = await Dashboard.processLog(payload);
489
+
490
+ if (response.success) {
491
+ clearProgressEntry(timingsStorageKey);
492
+ clearProgressEntry(activeStepStorageKey);
493
+ setProcessTimings([]);
494
+ setResumableStep(null);
495
+ return true;
496
+ }
497
+ } catch (e) {
498
+ console.error('Error:', e);
499
+ saveTimings(finalTimings);
500
+ }
501
+ return false;
502
+ };
503
+ /**
504
+ * @param {number} index
505
+ * @param {string} status
506
+ *
507
+ * Step Navigation
508
+ * - Records time spent on the current step.
509
+ * - Saves updated step timing data.
510
+ * - Navigates to the specified step index.
511
+ */
512
+ const gotoStep = (index, status = 'completed') => {
513
+ if (!steps.length) {
514
+ return;
515
+ }
516
+
517
+ const nextIndex = Math.max(0, Math.min(index, steps.length - 1));
518
+
519
+ if (nextIndex === activeStep) {
520
+ return;
521
+ }
522
+
523
+ setStepSlideDirection(nextIndex > activeStep ? 'forward' : 'backward');
524
+
525
+ const updated = recordStepTime(status);
526
+ saveTimings(updated);
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
+ }
541
+ };
542
+ /**
543
+ * Navigate to the next step
544
+ * - Records timing data and advances step index by one.
545
+ */
546
+ const handleNext = () => gotoStep(activeStep + 1);
547
+ /**
548
+ * Navigate to the previous step
549
+ * - Records timing data and moves to the previous step.
550
+ */
551
+ const handlePrevious = () => gotoStep(activeStep - 1);
552
+ /**
553
+ * Skip current step
554
+ * - Records timing with skipped status.
555
+ * - Moves to the next step.
556
+ */
557
+ const handleSkip = () => gotoStep(activeStep + 1, 'skipped');
558
+ /**
559
+ * Breadcrumb Navigation
560
+ * - Navigates directly to the selected step.
561
+ * - Records timing data for the current step.
562
+ */
563
+ const handleTimelineClick = (i) => gotoStep(i);
564
+ /**
565
+ * Resume Handlers
566
+ * - `handleResume` jumps to the persisted step (clamped to current step
567
+ * range) so a returning user picks up where they left off.
568
+ * - `dismissResume` discards the saved marker so the banner does not appear
569
+ * again for this process.
570
+ */
571
+ const handleResume = () => {
572
+ if (resumableStep == null || !steps.length) {
573
+ return;
574
+ }
575
+ const target = Math.max(0, Math.min(resumableStep, steps.length - 1));
576
+ setResumableStep(null);
577
+ if (target !== activeStep) {
578
+ gotoStep(target);
579
+ }
580
+ };
581
+ const dismissResume = () => {
582
+ setResumableStep(null);
583
+ try {
584
+ clearProgressEntry(activeStepStorageKey);
585
+ } catch (error) {
586
+ console.warn('Unable to clear resume marker from local storage.', error);
587
+ }
588
+ };
589
+ /**
590
+ * Process Completion
591
+ * - Records final step timing.
592
+ * - Submits process completion data.
593
+ * - Navigates back on successful completion.
594
+ */
595
+ const handleFinish = async () => {
596
+ const final = recordStepTime();
597
+ const success = await handleProcessSubmit(final);
598
+ if (success && !nextProcessId) props.history?.goBack();
599
+ return success;
600
+ };
601
+ /**
602
+ * Start Next Process
603
+ * - Records final timing of the current process.
604
+ * - Submits current process data.
605
+ * - Loads and initializes the next linked process.
606
+ */
607
+ const handleStartNextProcess = async () => {
608
+ const final = recordStepTime();
609
+ if (await handleProcessSubmit(final)) {
610
+ await loadProcess(nextProcessId.next_process_id);
611
+ setCurrentProcessId(nextProcessId.next_process_id);
612
+ setActiveStep(0);
613
+ setShowExternalWindow(true);
614
+ }
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
+ };
633
+
634
+ function clearNarrationAudio() {
635
+ if (narrationAudioRef.current) {
636
+ narrationAudioRef.current.pause();
637
+ narrationAudioRef.current.src = '';
638
+ narrationAudioRef.current = null;
639
+ }
640
+
641
+ if (narrationAudioUrlRef.current && typeof window !== 'undefined' && window.URL) {
642
+ window.URL.revokeObjectURL(narrationAudioUrlRef.current);
643
+ narrationAudioUrlRef.current = null;
644
+ }
645
+ }
646
+
647
+ function stopNarration() {
648
+ clearNarrationAudio();
649
+
650
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
651
+ window.speechSynthesis.cancel();
652
+ }
653
+
654
+ narrationUtteranceRef.current = null;
655
+ }
656
+
657
+ function buildRealtimeInstructions() {
658
+ const step = steps[activeStep];
659
+ const stepName = step?.step_name || `Step ${activeStep + 1}`;
660
+ const stepDescription = step?.step_description || 'No additional description.';
661
+
662
+ return [
663
+ 'You are a warm, concise healthcare concierge assisting a guest during a guided process.',
664
+ `Current step: ${stepName}.`,
665
+ `Step description: ${stepDescription}.`,
666
+ 'Answer in short, helpful sentences and keep the guest calm and informed.',
667
+ 'Avoid medical diagnosis or treatment advice.',
668
+ ].join(' ');
669
+ }
670
+
671
+ async function startRealtimeConversation() {
672
+ if (realtimeSessionRef.current) {
673
+ return;
674
+ }
675
+
676
+ const session = createOpenAIRealtimeSession({
677
+ instructions: buildRealtimeInstructions(),
678
+ onStatus: (status) => {
679
+ setRealtimeStatus(status);
680
+ },
681
+ onError: (error) => {
682
+ console.error('OpenAI Realtime error:', error);
683
+ message.error(error?.message || 'OpenAI Realtime connection failed.');
684
+ },
685
+ });
686
+
687
+ realtimeSessionRef.current = session;
688
+ try {
689
+ await session.connect();
690
+ } catch (error) {
691
+ realtimeSessionRef.current = null;
692
+ }
693
+ }
694
+
695
+ function stopRealtimeConversation() {
696
+ if (realtimeSessionRef.current) {
697
+ realtimeSessionRef.current.disconnect();
698
+ realtimeSessionRef.current = null;
699
+ }
700
+ setRealtimeStatus('idle');
701
+ }
702
+
703
+ function playAudioBlob(audioBlob) {
704
+ return new Promise((resolve, reject) => {
705
+ if (typeof window === 'undefined' || !window.Audio || !window.URL) {
706
+ reject(new Error('Audio playback is not available.'));
707
+ return;
708
+ }
709
+
710
+ const audioUrl = window.URL.createObjectURL(audioBlob);
711
+ const audio = new window.Audio(audioUrl);
712
+
713
+ narrationAudioRef.current = audio;
714
+ narrationAudioUrlRef.current = audioUrl;
715
+
716
+ const cleanup = () => {
717
+ if (narrationAudioRef.current === audio) {
718
+ narrationAudioRef.current = null;
719
+ }
720
+
721
+ if (narrationAudioUrlRef.current === audioUrl) {
722
+ window.URL.revokeObjectURL(audioUrl);
723
+ narrationAudioUrlRef.current = null;
724
+ }
725
+ };
726
+
727
+ audio.onended = () => {
728
+ cleanup();
729
+ resolve();
730
+ };
731
+
732
+ audio.onpause = () => {
733
+ cleanup();
734
+ resolve();
735
+ };
736
+
737
+ audio.onerror = () => {
738
+ cleanup();
739
+ reject(new Error('Audio playback failed.'));
740
+ };
741
+
742
+ audio.play().catch((error) => {
743
+ cleanup();
744
+ reject(error);
745
+ });
746
+ });
747
+ }
748
+
749
+ function speakWithBrowser(text) {
750
+ return new Promise((resolve, reject) => {
751
+ if (typeof window === 'undefined' || !window.speechSynthesis || !window.SpeechSynthesisUtterance) {
752
+ reject(new Error('Speech synthesis is not available.'));
753
+ return;
754
+ }
755
+
756
+ const utterance = new window.SpeechSynthesisUtterance(text);
757
+ utterance.lang = process.env.REACT_APP_STEP_TTS_LANG || 'en-US';
758
+
759
+ const rate = Number(process.env.REACT_APP_STEP_TTS_RATE || 1);
760
+ const pitch = Number(process.env.REACT_APP_STEP_TTS_PITCH || 1);
761
+
762
+ utterance.rate = Number.isFinite(rate) ? rate : 1;
763
+ utterance.pitch = Number.isFinite(pitch) ? pitch : 1;
764
+
765
+ const selectedBrowserVoice = voiceSelections.browser;
766
+ if (selectedBrowserVoice) {
767
+ const browserVoice = window.speechSynthesis.getVoices().find((voice) => (voice.voiceURI || voice.name) === selectedBrowserVoice);
768
+
769
+ if (browserVoice) {
770
+ utterance.voice = browserVoice;
771
+ }
772
+ }
773
+
774
+ utterance.onend = () => {
775
+ if (narrationUtteranceRef.current === utterance) {
776
+ narrationUtteranceRef.current = null;
777
+ }
778
+ resolve();
779
+ };
780
+
781
+ utterance.onerror = () => {
782
+ if (narrationUtteranceRef.current === utterance) {
783
+ narrationUtteranceRef.current = null;
784
+ }
785
+ reject(new Error('Browser narration failed.'));
786
+ };
787
+
788
+ narrationUtteranceRef.current = utterance;
789
+ window.speechSynthesis.speak(utterance);
790
+ });
791
+ }
792
+
793
+ async function synthesizeGeminiAudio(text) {
794
+ const apiKey = getGeminiApiKey();
795
+
796
+ if (!apiKey) {
797
+ throw new Error('Gemini API key is missing.');
798
+ }
799
+
800
+ const selectedVoiceName = voiceSelections.gemini || DEFAULT_GEMINI_TTS_VOICE;
801
+ const endpoint = `${GEMINI_TTS_API_BASE_URL}/models/${GEMINI_TTS_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`;
802
+ const response = await fetch(endpoint, {
803
+ method: 'POST',
804
+ headers: {
805
+ 'Content-Type': 'application/json',
806
+ },
807
+ body: JSON.stringify({
808
+ contents: [
809
+ {
810
+ role: 'user',
811
+ parts: [{ text }],
812
+ },
813
+ ],
814
+ generationConfig: {
815
+ responseModalities: ['AUDIO'],
816
+ speechConfig: {
817
+ voiceConfig: {
818
+ prebuiltVoiceConfig: {
819
+ voiceName: selectedVoiceName,
820
+ },
821
+ },
822
+ },
823
+ },
824
+ }),
825
+ });
826
+
827
+ if (!response.ok) {
828
+ throw new Error(`Gemini TTS request failed with status ${response.status}.`);
829
+ }
830
+
831
+ const payload = await response.json();
832
+ const audio = extractGeminiAudio(payload);
833
+
834
+ if (!audio || !audio.data) {
835
+ throw new Error('Gemini did not return audio data.');
836
+ }
837
+
838
+ return base64AudioToBlob(audio.data, audio.mimeType || 'audio/wav');
839
+ }
840
+
841
+ async function synthesizeOpenAIAudio(text) {
842
+ const apiKey = getOpenAIApiKey();
843
+
844
+ if (!apiKey) {
845
+ throw new Error('OpenAI API key is missing.');
846
+ }
847
+
848
+ const selectedVoice = voiceSelections.openai || DEFAULT_OPENAI_TTS_VOICE;
849
+ const response = await fetch(OPENAI_TTS_ENDPOINT, {
850
+ method: 'POST',
851
+ headers: {
852
+ 'Content-Type': 'application/json',
853
+ Authorization: `Bearer ${apiKey}`,
854
+ },
855
+ body: JSON.stringify({
856
+ model: OPENAI_TTS_MODEL,
857
+ voice: selectedVoice,
858
+ input: text,
859
+ response_format: OPENAI_TTS_FORMAT,
860
+ }),
861
+ });
862
+
863
+ if (!response.ok) {
864
+ throw new Error(`OpenAI TTS request failed with status ${response.status}.`);
865
+ }
866
+
867
+ return response.blob();
868
+ }
869
+
870
+ async function synthesizeElevenLabsAudio(text) {
871
+ const apiKey = getElevenLabsApiKey();
872
+
873
+ if (!apiKey) {
874
+ throw new Error('ElevenLabs API key is missing.');
875
+ }
876
+
877
+ const selectedVoiceId = voiceSelections.elevenlabs || DEFAULT_ELEVENLABS_VOICE_ID;
878
+ const endpoint = `${ELEVENLABS_TTS_API_BASE_URL}/${encodeURIComponent(selectedVoiceId)}/stream?output_format=${encodeURIComponent(
879
+ ELEVENLABS_OUTPUT_FORMAT
880
+ )}`;
881
+ const response = await fetch(endpoint, {
882
+ method: 'POST',
883
+ headers: {
884
+ 'Content-Type': 'application/json',
885
+ Accept: 'audio/mpeg',
886
+ 'xi-api-key': apiKey,
887
+ },
888
+ body: JSON.stringify({
889
+ text,
890
+ model_id: ELEVENLABS_MODEL_ID,
891
+ }),
892
+ });
893
+
894
+ if (!response.ok) {
895
+ throw new Error(`ElevenLabs TTS request failed with status ${response.status}.`);
896
+ }
897
+
898
+ return response.blob();
899
+ }
900
+
901
+ async function synthesizeSarvamAudio(text) {
902
+ const apiKey = getSarvamApiKey();
903
+
904
+ if (!apiKey) {
905
+ throw new Error('Sarvam API key is missing.');
906
+ }
907
+
908
+ const selectedSpeaker = voiceSelections.sarvam || SARVAM_VOICE_OPTIONS[0].value;
909
+ const response = await fetch(SARVAM_TTS_ENDPOINT, {
910
+ method: 'POST',
911
+ headers: {
912
+ 'Content-Type': 'application/json',
913
+ 'api-subscription-key': apiKey,
914
+ },
915
+ body: JSON.stringify({
916
+ text,
917
+ target_language_code: SARVAM_TARGET_LANGUAGE_CODE,
918
+ model: SARVAM_TTS_MODEL,
919
+ speaker: selectedSpeaker,
920
+ output_audio_codec: SARVAM_OUTPUT_AUDIO_CODEC,
921
+ }),
922
+ });
923
+
924
+ const payload = await response.json().catch(() => null);
925
+
926
+ if (!response.ok) {
927
+ throw new Error(`Sarvam TTS request failed with status ${response.status}.`);
928
+ }
929
+
930
+ const audioBase64 = payload?.audios?.[0];
931
+ if (!audioBase64) {
932
+ throw new Error('Sarvam did not return any audio data.');
933
+ }
934
+
935
+ const codec = (SARVAM_OUTPUT_AUDIO_CODEC || '').toLowerCase();
936
+ const mimeType = codec === 'mp3' ? 'audio/mpeg' : 'audio/wav';
937
+
938
+ return base64AudioToBlob(audioBase64, mimeType);
939
+ }
940
+
941
+ async function speakText(text) {
942
+ if (!text || typeof window === 'undefined') {
943
+ return;
944
+ }
945
+
946
+ stopNarration();
947
+
948
+ if (voiceProvider === 'gemini') {
949
+ const geminiAudio = await synthesizeGeminiAudio(text);
950
+ await playAudioBlob(geminiAudio);
951
+ return;
952
+ }
953
+
954
+ if (voiceProvider === 'elevenlabs') {
955
+ const elevenLabsAudio = await synthesizeElevenLabsAudio(text);
956
+ await playAudioBlob(elevenLabsAudio);
957
+ return;
958
+ }
959
+
960
+ if (voiceProvider === 'openai') {
961
+ const openAiAudio = await synthesizeOpenAIAudio(text);
962
+ await playAudioBlob(openAiAudio);
963
+ return;
964
+ }
965
+
966
+ if (voiceProvider === 'sarvam') {
967
+ const sarvamAudio = await synthesizeSarvamAudio(text);
968
+ await playAudioBlob(sarvamAudio);
969
+ return;
970
+ }
971
+
972
+ throw new Error('Browser narration is disabled. Use Gemini, ElevenLabs, or OpenAI.');
973
+ }
974
+
975
+ async function speakCurrentStep() {
976
+ const step = steps[activeStep];
977
+ const guide = buildGuestStepGuide(step, activeStep, steps.length);
978
+
979
+ try {
980
+ await speakText(guide.narration);
981
+ } catch (error) {
982
+ if (!narrationFallbackNoticeRef.current) {
983
+ const providerLabel =
984
+ voiceProvider === 'gemini'
985
+ ? 'Gemini'
986
+ : voiceProvider === 'elevenlabs'
987
+ ? 'ElevenLabs'
988
+ : voiceProvider === 'openai'
989
+ ? 'OpenAI'
990
+ : 'Selected provider';
991
+ message.warning(`${providerLabel} narration failed.`);
992
+ narrationFallbackNoticeRef.current = true;
993
+ }
994
+
995
+ message.error(error?.message || 'Unable to play narration for this step.');
996
+ }
997
+ }
998
+
999
+ async function toggleStepFullscreen() {
1000
+ if (typeof document === 'undefined') {
1001
+ return;
1002
+ }
1003
+
1004
+ const targetElement = fullscreenViewportRef.current;
1005
+
1006
+ if (!targetElement || !targetElement.requestFullscreen) {
1007
+ return;
1008
+ }
1009
+
1010
+ try {
1011
+ if (document.fullscreenElement === targetElement) {
1012
+ await document.exitFullscreen();
1013
+ return;
1014
+ }
1015
+
1016
+ if (document.fullscreenElement) {
1017
+ await document.exitFullscreen();
1018
+ }
1019
+
1020
+ await targetElement.requestFullscreen();
1021
+ } catch (error) {
1022
+ console.error('Failed to toggle step fullscreen mode:', error);
1023
+ }
1024
+ }
1025
+ /**
1026
+ * Dynamic Step Renderer
1027
+ * - Resolves and renders step-specific components dynamically.
1028
+ * - Passes configuration, parameters, and handlers to the component.
1029
+ * - Handles missing steps or components gracefully.
1030
+ */
1031
+ /**
1032
+ * Render the active step's dynamic component.
1033
+ *
1034
+ * Intentionally a plain function (not a component) called inline as
1035
+ * `{renderDynamicStep()}`. Defining it as a component inside the parent's
1036
+ * render body creates a fresh component *type* on every re-render — React
1037
+ * then unmounts and remounts the step component on every parent state
1038
+ * change (touchNavVisible, stepSlideDirection, activeStep timer, etc.),
1039
+ * which caused visible re-render/jitter during swipe navigation. Evaluating
1040
+ * it as a function just yields JSX for the real step Component, whose type
1041
+ * is stable, so React reconciles in place.
1042
+ */
1043
+ const renderDynamicStep = () => {
1044
+ const step = steps[activeStep];
1045
+ if (!step) return <Empty description="No step selected" />;
1046
+
1047
+ const Component = allComponents[step.related_page];
1048
+ if (!Component) return <Empty description={`Component "${step.related_page}" not found`} />;
1049
+
1050
+ return <Component {...step.config} {...props} step={step} params={urlParams} onStepComplete={() => setIsStepCompleted(true)} />;
1051
+ };
1052
+
1053
+ useEffect(() => {
1054
+ const handleKeyDown = (event) => {
1055
+ if (event.key === 'ArrowLeft' && activeStep > 0) {
1056
+ handlePrevious();
1057
+ }
1058
+
1059
+ if (event.key === 'ArrowRight' && activeStep < steps.length - 1) {
1060
+ handleNext();
1061
+ }
1062
+ };
1063
+
1064
+ // main window (document!)
1065
+ document.addEventListener('keydown', handleKeyDown);
1066
+
1067
+ // external window (document!)
1068
+ if (externalWin && externalWin.document) {
1069
+ externalWin.document.addEventListener('keydown', handleKeyDown);
1070
+ }
1071
+
1072
+ return () => {
1073
+ document.removeEventListener('keydown', handleKeyDown);
1074
+
1075
+ if (externalWin && externalWin.document) {
1076
+ externalWin.document.removeEventListener('keydown', handleKeyDown);
1077
+ }
1078
+ };
1079
+ }, [activeStep, steps, externalWin]);
1080
+
1081
+ /**
1082
+ * Touch-device detection.
1083
+ * - Runs once on mount.
1084
+ * - Uses `matchMedia('(pointer: coarse)')` as the primary signal because it
1085
+ * targets the actual input hardware (covers touch laptops correctly) and
1086
+ * falls back to `ontouchstart` / `navigator.maxTouchPoints` for older
1087
+ * browsers.
1088
+ * - When neither signal matches, the effect bails out and `isTouchDevice`
1089
+ * stays false so desktop renders without any touch-only UI.
1090
+ */
1091
+ useEffect(() => {
1092
+ if (typeof window === 'undefined') {
1093
+ return undefined;
1094
+ }
1095
+
1096
+ const hasCoarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
1097
+ const hasTouch = 'ontouchstart' in window || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
1098
+
1099
+ if (!hasCoarsePointer && !hasTouch) {
1100
+ return undefined;
1101
+ }
1102
+
1103
+ setIsTouchDevice(true);
1104
+ }, []);
1105
+
1106
+ /**
1107
+ * Show the floating prev/next arrow buttons and reset their auto-hide timer.
1108
+ * - Any pending hide timeout is cleared so a rapid sequence of touches keeps
1109
+ * the arrows on-screen continuously instead of flickering.
1110
+ * - A fresh timeout is scheduled for TOUCH_NAV_HIDE_DELAY so the arrows fade
1111
+ * away once the user stops interacting, keeping the step content clear.
1112
+ */
1113
+ const revealTouchNav = () => {
1114
+ if (typeof window === 'undefined') {
1115
+ return;
1116
+ }
1117
+
1118
+ setTouchNavVisible(true);
1119
+
1120
+ if (touchNavHideTimeoutRef.current) {
1121
+ window.clearTimeout(touchNavHideTimeoutRef.current);
1122
+ }
1123
+
1124
+ touchNavHideTimeoutRef.current = window.setTimeout(() => {
1125
+ setTouchNavVisible(false);
1126
+ touchNavHideTimeoutRef.current = null;
1127
+ }, TOUCH_NAV_HIDE_DELAY);
1128
+ };
1129
+
1130
+ /**
1131
+ * onTouchStart for the stage body.
1132
+ * - Records the initial touch position so handleStageTouchEnd can measure
1133
+ * the swipe delta.
1134
+ * - Also reveals the side arrows immediately, giving the user a visible
1135
+ * navigation affordance as soon as they touch the screen.
1136
+ */
1137
+ const handleStageTouchStart = (event) => {
1138
+ if (!isTouchDevice || !event.touches || !event.touches.length) {
1139
+ return;
1140
+ }
1141
+
1142
+ const touch = event.touches[0];
1143
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
1144
+ revealTouchNav();
1145
+ };
1146
+
1147
+ /**
1148
+ * onTouchEnd for the stage body.
1149
+ * - Computes the horizontal/vertical delta against the stored touch origin.
1150
+ * - Ignores gestures that are vertical-dominant or below the distance
1151
+ * threshold, so normal scrolling and short taps are not hijacked.
1152
+ * - A left swipe advances to the next step (subject to the same
1153
+ * `isStepCompleted` / final-step rules as the visible Next button); a
1154
+ * right swipe goes back. Each successful swipe re-reveals the arrows so
1155
+ * the user can continue tapping if they prefer.
1156
+ */
1157
+ const handleStageTouchEnd = (event) => {
1158
+ const start = touchStartRef.current;
1159
+ touchStartRef.current = null;
1160
+
1161
+ if (!start || !event.changedTouches || !event.changedTouches.length) {
1162
+ return;
1163
+ }
1164
+
1165
+ const touch = event.changedTouches[0];
1166
+ const deltaX = touch.clientX - start.x;
1167
+ const deltaY = touch.clientY - start.y;
1168
+
1169
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
1170
+ return;
1171
+ }
1172
+ if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD) {
1173
+ return;
1174
+ }
1175
+ if (Math.abs(deltaY) > SWIPE_VERTICAL_TOLERANCE) {
1176
+ return;
1177
+ }
1178
+
1179
+ if (deltaX < 0) {
1180
+ const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1181
+ if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1182
+ handleNext();
1183
+ revealTouchNav();
1184
+ }
1185
+ } else if (activeStep > 0) {
1186
+ handlePrevious();
1187
+ revealTouchNav();
1188
+ }
1189
+ };
1190
+
1191
+ /**
1192
+ * Cleanup any pending auto-hide timeout on unmount so the callback cannot
1193
+ * fire against a stale component and emit a React warning.
1194
+ */
1195
+ useEffect(() => {
1196
+ return () => {
1197
+ if (typeof window !== 'undefined' && touchNavHideTimeoutRef.current) {
1198
+ window.clearTimeout(touchNavHideTimeoutRef.current);
1199
+ }
1200
+ };
1201
+ }, []);
1202
+
1203
+ useEffect(() => {
1204
+ if (typeof document === 'undefined') {
1205
+ return undefined;
1206
+ }
1207
+
1208
+ const handleFullscreenChange = () => {
1209
+ setIsStepFullscreen(document.fullscreenElement === fullscreenViewportRef.current);
1210
+ };
1211
+
1212
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
1213
+ handleFullscreenChange();
1214
+
1215
+ return () => {
1216
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
1217
+ };
1218
+ }, []);
1219
+
1220
+ useEffect(() => {
1221
+ if (!NARRATION_CONTROLS_ENABLED || !autoNarration) {
1222
+ return;
1223
+ }
1224
+
1225
+ if (loading || !steps.length || !steps[activeStep]) {
1226
+ return;
1227
+ }
1228
+
1229
+ speakCurrentStep();
1230
+ }, [
1231
+ activeStep,
1232
+ steps,
1233
+ loading,
1234
+ autoNarration,
1235
+ voiceProvider,
1236
+ voiceSelections.browser,
1237
+ voiceSelections.elevenlabs,
1238
+ voiceSelections.openai,
1239
+ voiceSelections.sarvam,
1240
+ ]);
1241
+
1242
+ useEffect(() => {
1243
+ const session = realtimeSessionRef.current;
1244
+ if (!session || session.status !== 'connected') {
1245
+ return;
1246
+ }
1247
+
1248
+ session.sendEvent({
1249
+ type: 'session.update',
1250
+ session: {
1251
+ instructions: buildRealtimeInstructions(),
1252
+ },
1253
+ });
1254
+ }, [activeStep, steps]);
1255
+
1256
+ useEffect(() => {
1257
+ return () => {
1258
+ stopNarration();
1259
+ stopRealtimeConversation();
1260
+ };
1261
+ }, []);
1262
+
1263
+ /**
1264
+ * Renders the main process UI including breadcrumb, step details,
1265
+ * and action buttons. This content is reused in both normal view
1266
+ * and external window view.
1267
+ */
1268
+ const renderContent = () => {
1269
+ const currentStep = steps[activeStep];
1270
+ const isFinalStep = currentStep?.order_seqtype === 'E';
1271
+ const currentVoiceOptions =
1272
+ voiceProvider === 'gemini'
1273
+ ? GEMINI_VOICE_OPTIONS
1274
+ : voiceProvider === 'elevenlabs'
1275
+ ? ELEVENLABS_VOICE_OPTIONS
1276
+ : voiceProvider === 'openai'
1277
+ ? OPENAI_TTS_VOICE_OPTIONS
1278
+ : SARVAM_VOICE_OPTIONS;
1279
+ const currentVoiceValue = voiceSelections[voiceProvider] || undefined;
1280
+ const openAiTokenEndpoint = process.env.OPENAI_REALTIME_TOKEN_ENDPOINT || process.env.REACT_APP_OPENAI_REALTIME_TOKEN_ENDPOINT;
1281
+ const canStartRealtime = hasOpenAIRealtimeCredentials(openAiTokenEndpoint);
1282
+
1283
+ return (
1284
+ <div className="process-steps-page">
1285
+ <div ref={fullscreenViewportRef} className="steps-viewport">
1286
+ <Card className="steps-main-card">
1287
+ {/* {activeStep > 0 && GuestInfoComponent && (
1288
+ <div className="steps-patient-bar">
1289
+ <GuestInfoComponent params={urlParams} />
1290
+ </div>
1291
+ )} */}
1292
+
1293
+ <div className="steps-top-bar">
1294
+ <div className="steps-breadcrumb-strip">
1295
+ {steps.length ? (
1296
+ steps.map((stepItem, stepIndex) => {
1297
+ const isActiveBreadcrumb = stepIndex === activeStep;
1298
+ const isCompletedBreadcrumb = stepIndex < activeStep;
1299
+
1300
+ return (
1301
+ <button
1302
+ key={stepItem.step_id || `${stepItem.step_name || 'step'}_${stepIndex}`}
1303
+ type="button"
1304
+ className={`steps-breadcrumb-item${isActiveBreadcrumb ? ' active' : ''}${isCompletedBreadcrumb ? ' completed' : ''}`}
1305
+ onClick={() => handleTimelineClick(stepIndex)}
1306
+ >
1307
+ <span className="steps-breadcrumb-index">{stepIndex + 1}</span>
1308
+ <span className="steps-breadcrumb-label">{stepItem.step_name || `Step ${stepIndex + 1}`}</span>
1309
+ </button>
1310
+ );
1311
+ })
1312
+ ) : (
1313
+ <span className="steps-breadcrumb-empty">No steps loaded</span>
1314
+ )}
1315
+ </div>
1316
+
1317
+ <div className="steps-nav-actions">
1318
+ <Button type="dashed" icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />} onClick={toggleStepFullscreen}>
1319
+ {isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1320
+ </Button>
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
+
1335
+ {activeStep > 0 && (
1336
+ <Button type="default" icon={<ArrowLeftOutlined />} onClick={handlePrevious}>
1337
+ Back
1338
+ </Button>
1339
+ )}
1340
+
1341
+ {/* {activeStep > 0 && !isFinalStep && (
1342
+ <Button type="default" onClick={handleSkip}>
1343
+ Skip
1344
+ </Button>
1345
+ )} */}
1346
+
1347
+ {isFinalStep ? (
1348
+ <>
1349
+ {!showNextProcessAction && (
1350
+ <Button
1351
+ type="primary"
1352
+ onClick={async () => {
1353
+ const success = await handleFinish();
1354
+ if (success && nextProcessId?.next_process_id) {
1355
+ setShowNextProcessAction(true);
1356
+ }
1357
+ }}
1358
+ >
1359
+ Finish
1360
+ </Button>
1361
+ )}
1362
+ {showNextProcessAction && nextProcessId?.next_process_id && (
1363
+ <Button type="primary" onClick={handleStartNextProcess}>
1364
+ Start {nextProcessId.next_process_name} <ArrowRightOutlined />
1365
+ </Button>
1366
+ )}
1367
+ </>
1368
+ ) : (
1369
+ <Button type="primary" disabled={!isStepCompleted} onClick={handleNext}>
1370
+ {/*
1371
+ First-step label is resolved via FIRST_STEP_LABELS using
1372
+ the process name (lowercased + trimmed) as the key. Known
1373
+ processes get a tailored CTA (e.g. "Verify Profile",
1374
+ "Start Consultation"); unknown processes fall back to the
1375
+ generic "Next" label. All non-first steps always render
1376
+ "Next".
1377
+ */}
1378
+ {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'} <ArrowRightOutlined />
1379
+ </Button>
1380
+ )}
1381
+ </div>
1382
+ </div>
1383
+
1384
+ {/*
1385
+ Resume banner.
1386
+ - Renders only when a saved active step is ahead of the current
1387
+ position, so it stays out of the way during normal navigation
1388
+ and only surfaces when the user returns after an unexpected
1389
+ exit (refresh, tab close, navigation away).
1390
+ - Resuming clamps to the current step range; dismissing clears
1391
+ the persisted marker so the banner does not return for this
1392
+ process.
1393
+ */}
1394
+ {resumableStep != null && resumableStep > activeStep && resumableStep < steps.length ? (
1395
+ <div className="steps-resume-banner" role="status">
1396
+ <span className="steps-resume-banner-text">
1397
+ You left at Step {resumableStep + 1}
1398
+ {steps[resumableStep]?.step_name ? ` — ${steps[resumableStep].step_name}` : ''}.
1399
+ </span>
1400
+ <div className="steps-resume-banner-actions">
1401
+ <Button type="primary" size="small" onClick={handleResume}>
1402
+ Resume
1403
+ </Button>
1404
+ <Button type="text" size="small" onClick={dismissResume}>
1405
+ Dismiss
1406
+ </Button>
1407
+ </div>
1408
+ </div>
1409
+ ) : null}
1410
+
1411
+ <div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1412
+ {/*
1413
+ Stage body:
1414
+ - `is-swipe-enabled` applies `touch-action: pan-y` so horizontal
1415
+ gestures reach our handlers while vertical scrolling remains
1416
+ native.
1417
+ - Touch handlers are only attached on touch devices to keep
1418
+ desktop event trees untouched.
1419
+ */}
1420
+ <div
1421
+ className={`steps-stage-body${isTouchDevice ? ' is-swipe-enabled' : ''}`}
1422
+ onTouchStart={isTouchDevice ? handleStageTouchStart : undefined}
1423
+ onTouchEnd={isTouchDevice ? handleStageTouchEnd : undefined}
1424
+ >
1425
+ {/*
1426
+ Floating prev/next arrow buttons.
1427
+ - Rendered only on touch devices; `is-visible` class drives
1428
+ the fade-in/out via CSS transitions.
1429
+ - Disabled states mirror the visible Next/Back buttons in the
1430
+ top bar: previous disabled on the first step; next disabled
1431
+ on the last/final step or when the current step still
1432
+ requires user completion (isStepCompleted === false).
1433
+ - Clicking either button reveals the arrows again so the
1434
+ auto-hide timer restarts after every interaction.
1435
+ */}
1436
+ {isTouchDevice ? (
1437
+ <>
1438
+ <button
1439
+ type="button"
1440
+ className={`steps-touch-nav steps-touch-nav-left${touchNavVisible ? ' is-visible' : ''}`}
1441
+ aria-label="Previous step"
1442
+ disabled={activeStep === 0}
1443
+ onClick={() => {
1444
+ revealTouchNav();
1445
+ if (activeStep > 0) handlePrevious();
1446
+ }}
1447
+ >
1448
+ <ArrowLeftOutlined />
1449
+ </button>
1450
+ <button
1451
+ type="button"
1452
+ className={`steps-touch-nav steps-touch-nav-right${touchNavVisible ? ' is-visible' : ''}`}
1453
+ aria-label="Next step"
1454
+ disabled={activeStep >= steps.length - 1 || steps[activeStep]?.order_seqtype === 'E' || !isStepCompleted}
1455
+ onClick={() => {
1456
+ revealTouchNav();
1457
+ const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1458
+ if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1459
+ handleNext();
1460
+ }
1461
+ }}
1462
+ >
1463
+ <ArrowRightOutlined />
1464
+ </button>
1465
+ </>
1466
+ ) : null}
1467
+ <div
1468
+ key={`${currentProcessId}_${activeStep}`}
1469
+ className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
1470
+ >
1471
+ {/* <div className="steps-chat-step-top">
1472
+ <span className="steps-index-pill">
1473
+ Step {Math.min(activeStep + 1, steps.length || 1)} of {steps.length || 1}
1474
+ </span>
1475
+ <h2 className="steps-title">{currentStep?.step_name || 'No step selected'}</h2>
1476
+ {currentStep?.step_description ? <p className="steps-description">{currentStep.step_description}</p> : null}
1477
+ </div> */}
1478
+
1479
+ <div className="steps-chat-step-component">
1480
+ {loading ? (
1481
+ <div className="steps-chat-loading">
1482
+ <Spin />
1483
+ </div>
1484
+ ) : null}
1485
+ {!loading ? renderDynamicStep() : null}
1486
+ </div>
1487
+ </div>
1488
+ </div>
1489
+ </div>
1490
+
1491
+ {NARRATION_CONTROLS_ENABLED ? (
1492
+ <div className="steps-bottom-nav steps-narration-bar">
1493
+ <Select
1494
+ className="steps-voice-provider-select"
1495
+ value={voiceProvider}
1496
+ options={VOICE_PROVIDER_OPTIONS}
1497
+ onChange={(value) => setVoiceProvider(value)}
1498
+ />
1499
+ <Select
1500
+ className="steps-voice-select"
1501
+ value={currentVoiceValue}
1502
+ options={currentVoiceOptions}
1503
+ onChange={(value) =>
1504
+ setVoiceSelections((oldSelections) => ({
1505
+ ...oldSelections,
1506
+ [voiceProvider]: value,
1507
+ }))
1508
+ }
1509
+ placeholder="Select Voice"
1510
+ optionFilterProp="label"
1511
+ showSearch
1512
+ disabled={!currentVoiceOptions.length}
1513
+ />
1514
+ <Button icon={<SoundOutlined />} onClick={speakCurrentStep} disabled={!currentStep}>
1515
+ Read Step
1516
+ </Button>
1517
+ <Button
1518
+ type={realtimeStatus === 'connected' ? 'default' : 'primary'}
1519
+ disabled={!canStartRealtime}
1520
+ onClick={() => {
1521
+ if (realtimeStatus === 'connected' || realtimeStatus === 'connecting') {
1522
+ stopRealtimeConversation();
1523
+ return;
1524
+ }
1525
+ startRealtimeConversation();
1526
+ }}
1527
+ >
1528
+ {realtimeStatus === 'connected' ? 'Stop Conversation' : realtimeStatus === 'connecting' ? 'Connecting...' : 'Start Conversation'}
1529
+ </Button>
1530
+ <Button onClick={() => setAutoNarration((oldValue) => !oldValue)}>Auto Narration: {autoNarration ? 'On' : 'Off'}</Button>
1531
+ </div>
1532
+ ) : null}
1533
+ </Card>
1534
+ </div>
1535
+ </div>
1536
+ );
1537
+ };
1538
+ /**
1539
+ * Renders content in both the main window and an external window
1540
+ * when external window mode is enabled.
1541
+ */
1542
+ if (showExternalWindow && props.showExternalWindow) {
1543
+ return (
1544
+ <>
1545
+ <ExternalWindow
1546
+ onWindowReady={(win) => {
1547
+ setExternalWin(win);
1548
+ win.focus();
1549
+ }}
1550
+ title={steps[activeStep]?.step_name || 'Process Step'}
1551
+ onClose={() => setShowExternalWindow(false)}
1552
+ // left={window.screenX + window.outerWidth}
1553
+ // top={window.screenY}
1554
+ width={props.ExternalWindowWidth || 1000}
1555
+ height={props.ExternalWindowHeight || 1000}
1556
+ >
1557
+ {renderContent(false)}
1558
+ </ExternalWindow>
1559
+ {renderContent(true)}
1560
+ </>
1561
+ );
1562
+ }
1563
+ /**
1564
+ * Default render when external window mode is disabled.
1565
+ */
1566
+ return renderContent();
1567
+ }