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

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 (420) 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/PUBLISHING.md +333 -0
  7. package/babel.config.js +2 -2
  8. package/core/components/component-loader/component-loader.js +125 -125
  9. package/core/components/component-wrapper/component-wrapper.js +121 -121
  10. package/core/components/external-window/DEVELOPER_GUIDE.md +705 -705
  11. package/core/components/external-window/external-window.js +236 -236
  12. package/core/components/external-window/external-window.test.js +80 -80
  13. package/core/components/extra-info/extra-info-details.js +155 -155
  14. package/core/components/extra-info/extra-info-details.scss +26 -26
  15. package/core/components/extra-info/extra-info.js +134 -134
  16. package/core/components/index.js +12 -12
  17. package/core/components/landing-api/landing-api.js +707 -707
  18. package/core/components/landing-api/landing-api.scss +41 -41
  19. package/core/components/license-management/license-alert.js +97 -97
  20. package/core/components/menu-template-api/menu-template-api.js +321 -321
  21. package/core/components/root-application-api/root-application-api.js +174 -174
  22. package/core/index.js +13 -13
  23. package/core/lib/Store.js +369 -369
  24. package/core/lib/components/application-bootstrap/application-bootstrap.js +115 -115
  25. package/core/lib/components/approval-form/approval-form.js +280 -280
  26. package/core/lib/components/approval-form/approval-form.scss +183 -183
  27. package/core/lib/components/approval-list/approval-list.js +143 -143
  28. package/core/lib/components/approval-list/approval-list.scss +2 -2
  29. package/core/lib/components/approval-list/components/request-card/request-card.js +42 -42
  30. package/core/lib/components/approval-list/components/request-card/request-card.scss +30 -30
  31. package/core/lib/components/camera/camera.js +230 -230
  32. package/core/lib/components/camera/camera.scss +86 -86
  33. package/core/lib/components/comment-block/comment-block.js +138 -138
  34. package/core/lib/components/comment-block/comment-block.scss +3 -3
  35. package/core/lib/components/confirm-modal/confirm-modal.js +82 -82
  36. package/core/lib/components/confirm-modal/confirm-modal.scss +2 -2
  37. package/core/lib/components/consent/consent.js +67 -67
  38. package/core/lib/components/consent/pdf-signature.js +299 -299
  39. package/core/lib/components/consent/signature-pad.js +90 -90
  40. package/core/lib/components/consent/signature-pad.scss +14 -14
  41. package/core/lib/components/file-upload/file-upload.js +133 -133
  42. package/core/lib/components/finger-print-reader/finger-print-reader.js +295 -295
  43. package/core/lib/components/finger-print-reader/finger-print-reader.scss +47 -47
  44. package/core/lib/components/finger-print-search/finger-print-search.js +200 -200
  45. package/core/lib/components/finger-print-search/finger-print-search.scss +47 -47
  46. package/core/lib/components/global-header/animations.js +18 -18
  47. package/core/lib/components/global-header/global-header.js +286 -286
  48. package/core/lib/components/global-header/global-header.scss +397 -397
  49. package/core/lib/components/header/generic-header.js +76 -76
  50. package/core/lib/components/header/generic-header.scss +99 -99
  51. package/core/lib/components/image-preview/image-preview.js +33 -33
  52. package/core/lib/components/image-wrapper/image-wrapper.js +108 -108
  53. package/core/lib/components/image-wrapper/image-wrapper.scss +12 -12
  54. package/core/lib/components/index.js +206 -206
  55. package/core/lib/components/landing/landing.js +403 -403
  56. package/core/lib/components/language-switcher/language-switcher.js +49 -49
  57. package/core/lib/components/menu-context/menu-context.js +69 -69
  58. package/core/lib/components/menu-template/menu-template.js +249 -249
  59. package/core/lib/components/menu-template/menu-template.scss +9 -9
  60. package/core/lib/components/modal-search/modal-search.js +153 -153
  61. package/core/lib/components/modal-search/modal-search.scss +78 -78
  62. package/core/lib/components/modal-wrapper/modal-manager.js +15 -15
  63. package/core/lib/components/modal-wrapper/modal-wrapper.js +108 -108
  64. package/core/lib/components/modal-wrapper/modal-wrapper.scss +13 -13
  65. package/core/lib/components/notice-board/notice-board.js +132 -132
  66. package/core/lib/components/notice-board/notice-board.scss +65 -65
  67. package/core/lib/components/page-container/page-container.js +55 -55
  68. package/core/lib/components/page-container/page-container.scss +8 -8
  69. package/core/lib/components/page-header/page-header.js +23 -23
  70. package/core/lib/components/page-header/page-header.scss +17 -17
  71. package/core/lib/components/pdf-viewer/pdf-viewer.js +56 -56
  72. package/core/lib/components/portlet-table/components/table-actions/table-actions.js +58 -58
  73. package/core/lib/components/portlet-table/components/table-actions/table-actions.scss +1 -1
  74. package/core/lib/components/portlet-table/components/table-data/table-data.js +106 -106
  75. package/core/lib/components/portlet-table/portlet-table.js +63 -63
  76. package/core/lib/components/portlet-table/portlet-table.scss +90 -90
  77. package/core/lib/components/progress-bar/progress-bar.js +58 -58
  78. package/core/lib/components/progress-bar/progress-bar.scss +15 -15
  79. package/core/lib/components/request-form/request-form.js +110 -110
  80. package/core/lib/components/root-application/root-application.js +70 -70
  81. package/core/lib/components/rupee/rupee.js +14 -14
  82. package/core/lib/components/script-input/script-input.js +169 -169
  83. package/core/lib/components/script-input/script-input.scss +8 -8
  84. package/core/lib/components/sidemenu/animations.js +51 -51
  85. package/core/lib/components/sidemenu/sidemenu.js +713 -713
  86. package/core/lib/components/sidemenu/sidemenu.scss +314 -314
  87. package/core/lib/components/spotlight-search/spotlight-search.component.js +635 -635
  88. package/core/lib/components/spotlight-search/spotlight-search.component.scss +78 -78
  89. package/core/lib/components/table-wrapper/table-wrapper.js +135 -135
  90. package/core/lib/components/table-wrapper/table-wrapper.scss +72 -72
  91. package/core/lib/components/ui_elements/Loader.js +12 -12
  92. package/core/lib/components/ui_elements/Notify.js +12 -12
  93. package/core/lib/components/ui_elements/PlaceHolder.js +33 -33
  94. package/core/lib/components/web-camera/web-camera.js +161 -161
  95. package/core/lib/components/web-camera/web-camera.scss +28 -28
  96. package/core/lib/core.md +9 -9
  97. package/core/lib/elements/Elements.md +2 -2
  98. package/core/lib/elements/basic/LoggedUserRedirect.js +21 -21
  99. package/core/lib/elements/basic/PrivateRoute.js +16 -16
  100. package/core/lib/elements/basic/button/Button.md +43 -43
  101. package/core/lib/elements/basic/button/button.js +170 -170
  102. package/core/lib/elements/basic/card/Card.md +15 -15
  103. package/core/lib/elements/basic/card/card.js +40 -40
  104. package/core/lib/elements/basic/card/card.scss +13 -13
  105. package/core/lib/elements/basic/checkbox/checkbox.js +23 -23
  106. package/core/lib/elements/basic/col/col.js +15 -15
  107. package/core/lib/elements/basic/copy-to-clipboard/Readme.md +40 -40
  108. package/core/lib/elements/basic/copy-to-clipboard/copy-to-clipboard.js +61 -61
  109. package/core/lib/elements/basic/country-phone-input/Readme.md +98 -98
  110. package/core/lib/elements/basic/country-phone-input/country-phone-input.js +81 -81
  111. package/core/lib/elements/basic/country-phone-input/phone-input.scss +75 -75
  112. package/core/lib/elements/basic/datepicker/datepicker.js +33 -33
  113. package/core/lib/elements/basic/dragabble-wrapper/draggable-wrapper.js +203 -203
  114. package/core/lib/elements/basic/empty/empty.js +14 -14
  115. package/core/lib/elements/basic/fingerprint-protrected/fingerprint-protected.js +118 -118
  116. package/core/lib/elements/basic/fingerprint-protrected/fingerprint-protected.scss +10 -10
  117. package/core/lib/elements/basic/form/form.js +70 -70
  118. package/core/lib/elements/basic/form/form.scss +3 -3
  119. package/core/lib/elements/basic/image/image.js +45 -45
  120. package/core/lib/elements/basic/image/image.scss +17 -17
  121. package/core/lib/elements/basic/image/readme.md +26 -26
  122. package/core/lib/elements/basic/image-viewer/image-viewer.js +108 -108
  123. package/core/lib/elements/basic/image-viewer/image-viewer.scss +7 -7
  124. package/core/lib/elements/basic/input/input.js +81 -81
  125. package/core/lib/elements/basic/input/readme.md +77 -77
  126. package/core/lib/elements/basic/json-input/json-input.js +51 -51
  127. package/core/lib/elements/basic/menu-dashboard/menu-dashboard.js +216 -216
  128. package/core/lib/elements/basic/menu-dashboard/menu-dashboard.scss +28 -28
  129. package/core/lib/elements/basic/menu-tree/menu-tree.js +127 -127
  130. package/core/lib/elements/basic/modal/modal.js +64 -64
  131. package/core/lib/elements/basic/modal/readme.md +62 -62
  132. package/core/lib/elements/basic/popconfirm/popconfirm.js +17 -17
  133. package/core/lib/elements/basic/popover/popover.js +12 -12
  134. package/core/lib/elements/basic/radio/radio.js +18 -18
  135. package/core/lib/elements/basic/rangepicker/rangepicker.js +141 -141
  136. package/core/lib/elements/basic/rangepicker/rangepicker.scss +24 -24
  137. package/core/lib/elements/basic/rangepicker/readme.md +81 -81
  138. package/core/lib/elements/basic/reference-select/readme.md +18 -18
  139. package/core/lib/elements/basic/reference-select/reference-select.js +337 -337
  140. package/core/lib/elements/basic/row/row.js +15 -15
  141. package/core/lib/elements/basic/select/select.js +46 -46
  142. package/core/lib/elements/basic/select-box/readme.md +52 -52
  143. package/core/lib/elements/basic/select-box/select-box.js +63 -63
  144. package/core/lib/elements/basic/skeleton/readme.md +35 -35
  145. package/core/lib/elements/basic/skeleton/skeleton.js +35 -35
  146. package/core/lib/elements/basic/skeleton/skeleton.scss +53 -53
  147. package/core/lib/elements/basic/space/space.js +12 -12
  148. package/core/lib/elements/basic/switch/readme.md +29 -29
  149. package/core/lib/elements/basic/switch/switch.js +67 -67
  150. package/core/lib/elements/basic/tab/tab.js +14 -14
  151. package/core/lib/elements/basic/table/readme.md +8 -8
  152. package/core/lib/elements/basic/table/table.js +95 -95
  153. package/core/lib/elements/basic/tag/tag.js +63 -63
  154. package/core/lib/elements/basic/tag/tag.scss +2 -2
  155. package/core/lib/elements/basic/timeline/timeline.js +13 -13
  156. package/core/lib/elements/basic/title/readme.md +20 -20
  157. package/core/lib/elements/basic/title/title.js +37 -37
  158. package/core/lib/elements/basic/user-search/user-search.js +192 -192
  159. package/core/lib/elements/complex/barcode/barcode.js +27 -27
  160. package/core/lib/elements/complex/bargraph/bar-graph.js +262 -262
  161. package/core/lib/elements/complex/basic-table/basic-table.js +110 -110
  162. package/core/lib/elements/complex/basic-table/basic-table.scss +4 -4
  163. package/core/lib/elements/complex/date-display/date-display.js +37 -37
  164. package/core/lib/elements/complex/error-boundary/error-boundary.js +29 -29
  165. package/core/lib/elements/complex/google-location-input/map-container-library-load.js +92 -92
  166. package/core/lib/elements/complex/google-map/google-map.js +230 -230
  167. package/core/lib/elements/complex/google-map/google-map.scss +13 -13
  168. package/core/lib/elements/complex/line-graph/line-graph.js +108 -108
  169. package/core/lib/elements/complex/location-search-input/location-search-input.js +100 -100
  170. package/core/lib/elements/complex/pie-chart/pie-chart.js +202 -202
  171. package/core/lib/elements/complex/qr-code/qr-code.js +27 -27
  172. package/core/lib/elements/complex/qrscanner/qrscanner.js +57 -57
  173. package/core/lib/elements/complex/search-debounce/search-debounce.js +37 -37
  174. package/core/lib/elements/complex/statistic-card/dashboard-statistic-card.js +75 -75
  175. package/core/lib/elements/complex/statistic-card/statistic-card.js +28 -28
  176. package/core/lib/elements/index.js +226 -226
  177. package/core/lib/hooks/device-detect.js +25 -25
  178. package/core/lib/hooks/index.js +9 -9
  179. package/core/lib/hooks/use-location.js +33 -33
  180. package/core/lib/hooks/use-otp-timer.js +80 -80
  181. package/core/lib/hooks/use-window-size.js +34 -34
  182. package/core/lib/i18n.js +69 -69
  183. package/core/lib/index.js +106 -106
  184. package/core/lib/introduction.md +73 -73
  185. package/core/lib/js-styleguide.md +4112 -4112
  186. package/core/lib/models/actions/actions.js +127 -127
  187. package/core/lib/models/actions/components/action-detail/action-detail.js +190 -190
  188. package/core/lib/models/actions/components/custom-actions/custom-actions.js +185 -185
  189. package/core/lib/models/attachments/attachments.js +231 -231
  190. package/core/lib/models/base-loader.js +99 -99
  191. package/core/lib/models/base.js +716 -716
  192. package/core/lib/models/branches/branches.js +125 -125
  193. package/core/lib/models/checklists/checklists.js +114 -114
  194. package/core/lib/models/columns/columns.js +169 -169
  195. package/core/lib/models/columns/components/columns-add/columns-add.js +171 -171
  196. package/core/lib/models/comments/comments.js +213 -213
  197. package/core/lib/models/departments/departments.js +107 -107
  198. package/core/lib/models/financial-years/financial_years.js +127 -127
  199. package/core/lib/models/forms/components/form-creator/form-creator.js +665 -665
  200. package/core/lib/models/forms/components/form-creator/form-creator.scss +39 -39
  201. package/core/lib/models/forms/components/form-detail/form-detail.js +224 -224
  202. package/core/lib/models/forms/forms.js +121 -121
  203. package/core/lib/models/index.js +203 -203
  204. package/core/lib/models/invoice-numbers/invoice_numbers.js +204 -204
  205. package/core/lib/models/lookup-types/components/lookup-detail/lookup-detail.js +145 -145
  206. package/core/lib/models/lookup-types/lookup-types.js +113 -113
  207. package/core/lib/models/lookup-values/components/lookup-values-add/lookup-values-add.js +126 -126
  208. package/core/lib/models/lookup-values/lookup-values.js +107 -107
  209. package/core/lib/models/menu-roles/menu-roles.js +127 -127
  210. package/core/lib/models/menus/components/menu-add/menu-add.js +228 -228
  211. package/core/lib/models/menus/components/menu-detail/menu-detail.js +170 -170
  212. package/core/lib/models/menus/components/menu-list/menu-list.js +550 -550
  213. package/core/lib/models/menus/components/menu-list/menu-list.scss +5 -5
  214. package/core/lib/models/menus/components/menu-roles-add/menu-roles-add.js +183 -183
  215. package/core/lib/models/menus/menus.js +499 -499
  216. package/core/lib/models/models/components/model-detail/model-detail.js +137 -137
  217. package/core/lib/models/models/components/models.js +128 -128
  218. package/core/lib/models/modules/modules.js +204 -204
  219. package/core/lib/models/outbox/outbox.js +73 -73
  220. package/core/lib/models/pages/pages.js +107 -107
  221. package/core/lib/models/permissions/permissions.js +71 -71
  222. package/core/lib/models/process/components/process-add/process-add.js +181 -181
  223. package/core/lib/models/process/components/process-dashboard/process-dashboard.js +1068 -1068
  224. package/core/lib/models/process/components/process-dashboard/process-dashboard.scss +66 -66
  225. package/core/lib/models/process/components/process-detail/process-detail.js +140 -140
  226. package/core/lib/models/process/components/process-timeline/process-timeline.js +139 -139
  227. package/core/lib/models/process/components/task-detail/task-detail.js +240 -240
  228. package/core/lib/models/process/components/task-detail/task-detail.scss +27 -27
  229. package/core/lib/models/process/components/task-form/task-form.js +528 -528
  230. package/core/lib/models/process/components/task-form/task-form.scss +7 -7
  231. package/core/lib/models/process/components/task-list/task-list.js +221 -221
  232. package/core/lib/models/process/components/task-list/task-list.scss +14 -14
  233. package/core/lib/models/process/components/task-overview/task-overview.js +299 -299
  234. package/core/lib/models/process/components/task-overview-legacy/task-overview-legacy.js +192 -192
  235. package/core/lib/models/process/components/task-routes/task-routes.js +45 -45
  236. package/core/lib/models/process/components/task-status/task-status.js +175 -175
  237. package/core/lib/models/process/components/task-status/task-status.scss +11 -11
  238. package/core/lib/models/process/process.js +780 -780
  239. package/core/lib/models/process-transactions/process-transactions.js +123 -123
  240. package/core/lib/models/roles/roles.js +106 -106
  241. package/core/lib/models/scripts/scripts.js +111 -111
  242. package/core/lib/models/step-transactions/step-transcations.js +147 -147
  243. package/core/lib/models/steps/components/step-add/step-add.js +261 -261
  244. package/core/lib/models/steps/components/step-detail/step-detail.js +157 -157
  245. package/core/lib/models/steps/steps.js +356 -356
  246. package/core/lib/models/user-preferences/user-preferences.js +83 -83
  247. package/core/lib/models/users/components/user-add/user-add.js +226 -226
  248. package/core/lib/models/users/users.js +119 -119
  249. package/core/lib/modules/business/launch-page/launch-page.js +29 -29
  250. package/core/lib/modules/business/launch-page/launch-page.scss +5 -5
  251. package/core/lib/modules/business/slots/slots.js +231 -231
  252. package/core/lib/modules/business/slots/slots.scss +108 -108
  253. package/core/lib/modules/forms/components/field-customizer/field-customizer.js +138 -138
  254. package/core/lib/modules/forms/components/field-selector/field-selector.js +157 -157
  255. package/core/lib/modules/forms/components/field-selector/field-selector.scss +25 -25
  256. package/core/lib/modules/forms/components/form-display/form-display.js +203 -203
  257. package/core/lib/modules/forms/components/form-display/form-display.scss +9 -9
  258. package/core/lib/modules/forms/components/tab-customizer/tab-customizer.js +124 -124
  259. package/core/lib/modules/generic/generic-add/generic-add.js +213 -213
  260. package/core/lib/modules/generic/generic-detail/generic-detail.js +199 -199
  261. package/core/lib/modules/generic/generic-edit/generic-edit.js +120 -120
  262. package/core/lib/modules/generic/generic-list/ExportReactCSV.js +414 -414
  263. package/core/lib/modules/generic/generic-list/generic-list.js +705 -705
  264. package/core/lib/modules/generic/generic-list/generic-list.scss +68 -68
  265. package/core/lib/modules/generic/generic-upload/generic-upload.js +483 -483
  266. package/core/lib/modules/generic/table-settings/table-settings.js +226 -226
  267. package/core/lib/modules/generic/table-settings/table-settings.scss +37 -37
  268. package/core/lib/modules/index.js +52 -52
  269. package/core/lib/modules/modules-routes/module-routes.js +35 -35
  270. package/core/lib/pages/change-password/change-password.js +204 -204
  271. package/core/lib/pages/change-password/change-password.scss +73 -73
  272. package/core/lib/pages/homepage/homepage.js +53 -53
  273. package/core/lib/pages/index.js +19 -19
  274. package/core/lib/pages/login/commnication-mode-selection.js +46 -46
  275. package/core/lib/pages/login/communication-mode-selection.scss +60 -60
  276. package/core/lib/pages/login/login.js +872 -872
  277. package/core/lib/pages/login/login.scss +353 -353
  278. package/core/lib/pages/login/reset-password.js +124 -124
  279. package/core/lib/pages/login/reset-password.scss +31 -31
  280. package/core/lib/pages/manage-users/manage-users.js +429 -429
  281. package/core/lib/pages/manage-users/manage-users.scss +25 -25
  282. package/core/lib/pages/profile/profile.js +247 -247
  283. package/core/lib/pages/profile/profile.scss +107 -107
  284. package/core/lib/pages/profile/theme-config.js +18 -18
  285. package/core/lib/pages/profile/themes-backup.json +310 -310
  286. package/core/lib/pages/profile/themes.json +254 -254
  287. package/core/lib/pages/register/register.js +176 -176
  288. package/core/lib/pages/register/register.scss +128 -128
  289. package/core/lib/react-styleguide.md +756 -756
  290. package/core/lib/utils/api/api.utils.js +207 -207
  291. package/core/lib/utils/api/readme.md +426 -426
  292. package/core/lib/utils/async.js +35 -35
  293. package/core/lib/utils/common/common.utils.js +237 -237
  294. package/core/lib/utils/common/readme.md +30 -30
  295. package/core/lib/utils/date/date.utils.js +295 -295
  296. package/core/lib/utils/date/readme.md +2 -2
  297. package/core/lib/utils/firebase.support.utils.js +98 -98
  298. package/core/lib/utils/firebase.utils.js +808 -808
  299. package/core/lib/utils/font-awesome.utils.js +168 -168
  300. package/core/lib/utils/form/form.utils.js +255 -255
  301. package/core/lib/utils/generic/generic.utils.js +70 -70
  302. package/core/lib/utils/http/auth.helper.js +95 -95
  303. package/core/lib/utils/http/http.utils.js +186 -186
  304. package/core/lib/utils/http/readme.md +14 -14
  305. package/core/lib/utils/index.js +43 -43
  306. package/core/lib/utils/location/location.utils.js +137 -137
  307. package/core/lib/utils/location/readme.md +18 -18
  308. package/core/lib/utils/modal.utils.js +15 -15
  309. package/core/lib/utils/notification.utils.js +34 -34
  310. package/core/lib/utils/pwa/pwa.utils.js +88 -88
  311. package/core/lib/utils/script.utils.js +235 -235
  312. package/core/lib/utils/setting.utils.js +68 -68
  313. package/core/lib/utils/upload.utils.js +29 -29
  314. package/core/models/Preference/Preferences.js +46 -46
  315. package/core/models/base/base.js +403 -403
  316. package/core/models/base-clone-loader.js +107 -107
  317. package/core/models/base-clone.js +187 -187
  318. package/core/models/base-loader.js +97 -97
  319. package/core/models/core-scripts/core-scripts.js +179 -179
  320. package/core/models/dashboard/dashboard.js +201 -201
  321. package/core/models/detail-loader.js +88 -88
  322. package/core/models/doctor/components/doctor-add/doctor-add.js +432 -432
  323. package/core/models/doctor/components/doctor-add/doctor-add.scss +32 -32
  324. package/core/models/groups.js +82 -82
  325. package/core/models/index.js +100 -100
  326. package/core/models/lookup-types/components/lookup-detail/lookup-detail.js +129 -129
  327. package/core/models/lookup-types/lookup-types.js +96 -96
  328. package/core/models/lookup-values/components/lookup-values-modal/lookup-values-modal.js +95 -95
  329. package/core/models/lookup-values/lookup-values.js +92 -92
  330. package/core/models/menu-roles/components/menu-roles-add/menu-roles-add.js +153 -153
  331. package/core/models/menu-roles/menu-roles.js +158 -158
  332. package/core/models/menus/components/menu-add/menu-add.js +288 -288
  333. package/core/models/menus/components/menu-add/menu-add.scss +31 -31
  334. package/core/models/menus/components/menu-detail/menu-detail.js +263 -263
  335. package/core/models/menus/components/menu-list/menu-list.js +392 -392
  336. package/core/models/menus/components/menu-lists/menu-lists.js +635 -584
  337. package/core/models/menus/components/menu-lists/menu-lists.scss +46 -46
  338. package/core/models/menus/menus.js +338 -338
  339. package/core/models/model-columns.js +121 -121
  340. package/core/models/models/components/model-detail/model-add.js +120 -120
  341. package/core/models/models/components/model-detail/model-detail.js +133 -133
  342. package/core/models/models/models.js +154 -154
  343. package/core/models/pages/components/page-add/page-add.js +163 -163
  344. package/core/models/pages/components/page-add/page-add.scss +30 -30
  345. package/core/models/pages/components/page-details/page-details.js +209 -209
  346. package/core/models/pages/components/page-list/page-list.js +248 -248
  347. package/core/models/pages/pages.js +142 -142
  348. package/core/models/pages.js +142 -142
  349. package/core/models/roles/components/role-add/menu-label.js +14 -14
  350. package/core/models/roles/components/role-add/menu-tree.js +127 -127
  351. package/core/models/roles/components/role-add/role-add.js +222 -222
  352. package/core/models/roles/components/role-add/role-add.scss +4 -4
  353. package/core/models/roles/components/role-list/role-list.js +406 -406
  354. package/core/models/roles/roles.js +196 -196
  355. package/core/models/staff/components/staff-add/staff-add.js +455 -455
  356. package/core/models/user-roles/components/user-roles-add/user-roles-add.js +149 -149
  357. package/core/models/user-roles/user-roles.js +113 -113
  358. package/core/models/users/components/assign-role/assign-role.js +428 -428
  359. package/core/models/users/components/assign-role/assign-role.scss +281 -281
  360. package/core/models/users/components/assign-role/avatar-props.js +45 -45
  361. package/core/models/users/components/user-add/user-add.js +847 -847
  362. package/core/models/users/components/user-add/user-edit.js +110 -110
  363. package/core/models/users/components/user-detail/user-detail.js +236 -236
  364. package/core/models/users/components/user-list/user-list.js +397 -397
  365. package/core/models/users/users.js +379 -379
  366. package/core/modules/Informations/change-info/change-info.js +618 -618
  367. package/core/modules/Informations/change-info/change-info.scss +134 -134
  368. package/core/modules/dashboard/components/dashboard-card/animations.js +64 -64
  369. package/core/modules/dashboard/components/dashboard-card/dashboard-card.js +197 -197
  370. package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.js +430 -430
  371. package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.scss +59 -59
  372. package/core/modules/dashboard/components/pop-query-dashboard/pop-query-dashboard.js +66 -66
  373. package/core/modules/generic/components/generic-add/generic-add.js +121 -121
  374. package/core/modules/generic/components/generic-add/generic-add.scss +13 -13
  375. package/core/modules/generic/components/generic-add-modal/generic-add-modal.js +125 -125
  376. package/core/modules/generic/components/generic-add-modal/generic-add-modal.scss +13 -13
  377. package/core/modules/generic/components/generic-detail/generic-detail.js +184 -184
  378. package/core/modules/generic/components/generic-detail/generic-detail.scss +25 -25
  379. package/core/modules/generic/components/generic-edit/generic-edit.js +123 -123
  380. package/core/modules/generic/components/generic-list/generic-list.js +335 -335
  381. package/core/modules/generic/components/generic-list/generic-list.scss +35 -35
  382. package/core/modules/index.js +42 -42
  383. package/core/modules/module-routes/module-routes.js +37 -37
  384. package/core/modules/reporting/components/index.js +6 -6
  385. package/core/modules/reporting/components/reporting-dashboard/README.md +316 -316
  386. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.js +271 -271
  387. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.scss +76 -76
  388. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.js +90 -90
  389. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.test.js +74 -74
  390. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +449 -449
  391. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.test.js +199 -199
  392. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +1116 -1116
  393. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.scss +215 -215
  394. package/core/modules/reporting/components/reporting-dashboard/reporting-table.js +519 -519
  395. package/core/modules/steps/action-buttons.js +92 -92
  396. package/core/modules/steps/action-buttons.scss +62 -62
  397. package/core/modules/steps/chat-assistant.js +141 -141
  398. package/core/modules/steps/narration.js +192 -192
  399. package/core/modules/steps/openai-realtime.js +275 -275
  400. package/core/modules/steps/progress-storage.js +140 -140
  401. package/core/modules/steps/readme.md +167 -167
  402. package/core/modules/steps/steps.js +1567 -1567
  403. package/core/modules/steps/steps.scss +907 -907
  404. package/core/modules/steps/timeline.js +56 -56
  405. package/core/modules/steps/voice-navigation.js +709 -709
  406. package/core/pages/homepage-api/homepage-api.js +106 -106
  407. package/core/pages/homepage-api/homepage-api.scss +233 -233
  408. package/core/pages/homepage-api/menu-dashboard.js +169 -169
  409. package/core/pages/homepage-api/menu-dashboard.scss +11 -11
  410. package/core/translation.json +53 -53
  411. package/core/translations.json +19 -19
  412. package/core/utils/script.utils.js +129 -129
  413. package/core/utils/settings.utils.js +25 -25
  414. package/eslint.config.mjs +79 -79
  415. package/index.js +35 -35
  416. package/jest.config.js +7 -7
  417. package/jest.setup.js +1 -1
  418. package/package.json +124 -124
  419. package/tsconfig.json +26 -26
  420. 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
+ }