ui-soxo-bootstrap-core 2.6.56 → 2.6.59

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 (422) hide show
  1. package/.babelrc +8 -8
  2. package/.github/workflows/npm-publish.yml +41 -41
  3. package/.husky/pre-commit +11 -11
  4. package/.prettierrc.json +10 -10
  5. package/DEVELOPER_GUIDE.md +294 -294
  6. package/PUBLISHING.md +333 -333
  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 +14 -14
  17. package/core/components/landing-api/landing-api.js +733 -728
  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 +375 -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 +227 -227
  40. package/core/lib/components/consent/signature-pad.scss +23 -23
  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 +293 -293
  48. package/core/lib/components/global-header/global-header.scss +399 -399
  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 +632 -632
  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 +341 -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 +29 -29
  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 +667 -667
  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 +508 -508
  263. package/core/lib/modules/generic/generic-list/fonts/NotoSansBold.js +8 -8
  264. package/core/lib/modules/generic/generic-list/fonts/NotoSansRegular.js +15 -15
  265. package/core/lib/modules/generic/generic-list/generic-list.js +705 -705
  266. package/core/lib/modules/generic/generic-list/generic-list.scss +68 -68
  267. package/core/lib/modules/generic/generic-upload/generic-upload.js +483 -483
  268. package/core/lib/modules/generic/table-settings/table-settings.js +226 -226
  269. package/core/lib/modules/generic/table-settings/table-settings.scss +37 -37
  270. package/core/lib/modules/index.js +52 -52
  271. package/core/lib/modules/modules-routes/module-routes.js +35 -35
  272. package/core/lib/pages/change-password/change-password.js +204 -204
  273. package/core/lib/pages/change-password/change-password.scss +73 -73
  274. package/core/lib/pages/homepage/homepage.js +53 -53
  275. package/core/lib/pages/index.js +19 -19
  276. package/core/lib/pages/login/commnication-mode-selection.js +46 -46
  277. package/core/lib/pages/login/communication-mode-selection.scss +60 -60
  278. package/core/lib/pages/login/login.js +872 -872
  279. package/core/lib/pages/login/login.scss +353 -353
  280. package/core/lib/pages/login/reset-password.js +124 -124
  281. package/core/lib/pages/login/reset-password.scss +31 -31
  282. package/core/lib/pages/manage-users/manage-users.js +429 -429
  283. package/core/lib/pages/manage-users/manage-users.scss +25 -25
  284. package/core/lib/pages/profile/profile.js +247 -247
  285. package/core/lib/pages/profile/profile.scss +107 -107
  286. package/core/lib/pages/profile/theme-config.js +18 -18
  287. package/core/lib/pages/profile/themes-backup.json +310 -310
  288. package/core/lib/pages/profile/themes.json +254 -254
  289. package/core/lib/pages/register/register.js +176 -176
  290. package/core/lib/pages/register/register.scss +128 -128
  291. package/core/lib/react-styleguide.md +756 -756
  292. package/core/lib/utils/api/api.utils.js +207 -207
  293. package/core/lib/utils/api/readme.md +426 -426
  294. package/core/lib/utils/async.js +35 -35
  295. package/core/lib/utils/common/common.utils.js +237 -237
  296. package/core/lib/utils/common/readme.md +30 -30
  297. package/core/lib/utils/date/date.utils.js +295 -295
  298. package/core/lib/utils/date/readme.md +2 -2
  299. package/core/lib/utils/firebase.support.utils.js +98 -98
  300. package/core/lib/utils/firebase.utils.js +808 -808
  301. package/core/lib/utils/font-awesome.utils.js +168 -168
  302. package/core/lib/utils/form/form.utils.js +255 -255
  303. package/core/lib/utils/generic/generic.utils.js +70 -70
  304. package/core/lib/utils/http/auth.helper.js +95 -95
  305. package/core/lib/utils/http/http.utils.js +190 -190
  306. package/core/lib/utils/http/readme.md +14 -14
  307. package/core/lib/utils/index.js +43 -43
  308. package/core/lib/utils/location/location.utils.js +137 -137
  309. package/core/lib/utils/location/readme.md +18 -18
  310. package/core/lib/utils/modal.utils.js +15 -15
  311. package/core/lib/utils/notification.utils.js +34 -34
  312. package/core/lib/utils/pwa/pwa.utils.js +88 -88
  313. package/core/lib/utils/script.utils.js +235 -235
  314. package/core/lib/utils/setting.utils.js +68 -68
  315. package/core/lib/utils/upload.utils.js +29 -29
  316. package/core/models/Preference/Preferences.js +46 -46
  317. package/core/models/base/base.js +403 -403
  318. package/core/models/base-clone-loader.js +107 -107
  319. package/core/models/base-clone.js +187 -187
  320. package/core/models/base-loader.js +97 -97
  321. package/core/models/core-scripts/core-scripts.js +179 -179
  322. package/core/models/dashboard/dashboard.js +201 -201
  323. package/core/models/detail-loader.js +88 -88
  324. package/core/models/doctor/components/doctor-add/doctor-add.js +432 -432
  325. package/core/models/doctor/components/doctor-add/doctor-add.scss +32 -32
  326. package/core/models/groups.js +82 -82
  327. package/core/models/index.js +100 -100
  328. package/core/models/lookup-types/components/lookup-detail/lookup-detail.js +129 -129
  329. package/core/models/lookup-types/lookup-types.js +96 -96
  330. package/core/models/lookup-values/components/lookup-values-modal/lookup-values-modal.js +95 -95
  331. package/core/models/lookup-values/lookup-values.js +92 -92
  332. package/core/models/menu-roles/components/menu-roles-add/menu-roles-add.js +153 -153
  333. package/core/models/menu-roles/menu-roles.js +158 -158
  334. package/core/models/menus/components/menu-add/menu-add.js +315 -315
  335. package/core/models/menus/components/menu-add/menu-add.scss +31 -31
  336. package/core/models/menus/components/menu-detail/menu-detail.js +263 -263
  337. package/core/models/menus/components/menu-list/menu-list.js +392 -392
  338. package/core/models/menus/components/menu-lists/menu-lists.js +647 -647
  339. package/core/models/menus/components/menu-lists/menu-lists.scss +73 -73
  340. package/core/models/menus/menus.js +338 -338
  341. package/core/models/model-columns.js +121 -121
  342. package/core/models/models/components/model-detail/model-add.js +120 -120
  343. package/core/models/models/components/model-detail/model-detail.js +133 -133
  344. package/core/models/models/models.js +154 -154
  345. package/core/models/pages/components/page-add/page-add.js +163 -163
  346. package/core/models/pages/components/page-add/page-add.scss +30 -30
  347. package/core/models/pages/components/page-details/page-details.js +209 -209
  348. package/core/models/pages/components/page-list/page-list.js +248 -248
  349. package/core/models/pages/pages.js +142 -142
  350. package/core/models/pages.js +142 -142
  351. package/core/models/roles/components/role-add/menu-label.js +14 -14
  352. package/core/models/roles/components/role-add/menu-tree.js +127 -127
  353. package/core/models/roles/components/role-add/role-add.js +230 -230
  354. package/core/models/roles/components/role-add/role-add.scss +4 -4
  355. package/core/models/roles/components/role-list/role-list.js +406 -406
  356. package/core/models/roles/roles.js +199 -199
  357. package/core/models/staff/components/staff-add/staff-add.js +455 -455
  358. package/core/models/user-roles/components/user-roles-add/user-roles-add.js +149 -149
  359. package/core/models/user-roles/user-roles.js +113 -113
  360. package/core/models/users/components/assign-role/assign-role.js +428 -428
  361. package/core/models/users/components/assign-role/assign-role.scss +281 -281
  362. package/core/models/users/components/assign-role/avatar-props.js +45 -45
  363. package/core/models/users/components/user-add/user-add.js +847 -847
  364. package/core/models/users/components/user-add/user-edit.js +110 -110
  365. package/core/models/users/components/user-detail/user-detail.js +236 -236
  366. package/core/models/users/components/user-list/user-list.js +397 -397
  367. package/core/models/users/users.js +379 -379
  368. package/core/modules/Informations/change-info/change-info.js +618 -618
  369. package/core/modules/Informations/change-info/change-info.scss +134 -134
  370. package/core/modules/dashboard/components/dashboard-card/animations.js +64 -64
  371. package/core/modules/dashboard/components/dashboard-card/dashboard-card.js +197 -197
  372. package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.js +430 -430
  373. package/core/modules/dashboard/components/dashboard-card/menu-dashboard-card.scss +59 -59
  374. package/core/modules/dashboard/components/pop-query-dashboard/pop-query-dashboard.js +66 -66
  375. package/core/modules/generic/components/generic-add/generic-add.js +121 -121
  376. package/core/modules/generic/components/generic-add/generic-add.scss +13 -13
  377. package/core/modules/generic/components/generic-add-modal/generic-add-modal.js +125 -125
  378. package/core/modules/generic/components/generic-add-modal/generic-add-modal.scss +13 -13
  379. package/core/modules/generic/components/generic-detail/generic-detail.js +184 -184
  380. package/core/modules/generic/components/generic-detail/generic-detail.scss +25 -25
  381. package/core/modules/generic/components/generic-edit/generic-edit.js +123 -123
  382. package/core/modules/generic/components/generic-list/generic-list.js +335 -335
  383. package/core/modules/generic/components/generic-list/generic-list.scss +35 -35
  384. package/core/modules/index.js +42 -42
  385. package/core/modules/module-routes/module-routes.js +37 -37
  386. package/core/modules/reporting/components/index.js +6 -6
  387. package/core/modules/reporting/components/reporting-dashboard/README.md +316 -316
  388. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.js +271 -271
  389. package/core/modules/reporting/components/reporting-dashboard/adavance-search/advance-search.scss +76 -76
  390. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.js +90 -90
  391. package/core/modules/reporting/components/reporting-dashboard/display-columns/build-display-columns.test.js +74 -74
  392. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +404 -404
  393. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.test.js +179 -179
  394. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +652 -652
  395. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.scss +215 -215
  396. package/core/modules/reporting/components/reporting-dashboard/reporting-table.js +537 -522
  397. package/core/modules/steps/action-buttons.js +92 -92
  398. package/core/modules/steps/action-buttons.scss +62 -62
  399. package/core/modules/steps/chat-assistant.js +141 -141
  400. package/core/modules/steps/narration.js +192 -192
  401. package/core/modules/steps/openai-realtime.js +275 -275
  402. package/core/modules/steps/progress-storage.js +140 -140
  403. package/core/modules/steps/readme.md +167 -167
  404. package/core/modules/steps/steps.js +1597 -1597
  405. package/core/modules/steps/steps.scss +1051 -1051
  406. package/core/modules/steps/timeline.js +56 -56
  407. package/core/modules/steps/voice-navigation.js +709 -709
  408. package/core/pages/homepage-api/homepage-api.js +106 -106
  409. package/core/pages/homepage-api/homepage-api.scss +233 -233
  410. package/core/pages/homepage-api/menu-dashboard.js +169 -169
  411. package/core/pages/homepage-api/menu-dashboard.scss +11 -11
  412. package/core/translation.json +53 -53
  413. package/core/translations.json +19 -19
  414. package/core/utils/script.utils.js +129 -129
  415. package/core/utils/settings.utils.js +25 -25
  416. package/eslint.config.mjs +79 -79
  417. package/index.js +35 -35
  418. package/jest.config.js +7 -7
  419. package/jest.setup.js +1 -1
  420. package/package.json +125 -125
  421. package/tsconfig.json +26 -26
  422. package/webpack.config.js +173 -173
@@ -1,1597 +1,1597 @@
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, Skeleton, 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${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1294
- {/*
1295
- Full-screen toggle is anchored to the far left of the bar so the
1296
- step tabs can stretch across the middle and the primary CTA sits
1297
- flush right (see design).
1298
- */}
1299
- <Button
1300
- className="steps-fullscreen-toggle"
1301
- type="dashed"
1302
- icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />}
1303
- onClick={toggleStepFullscreen}
1304
- aria-label={isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1305
- title={isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1306
- >
1307
- {isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1308
- </Button>
1309
-
1310
- {/*
1311
- Breadcrumb strip stays visible in fullscreen mode too so the
1312
- user keeps the step timeline / navigation while presenting.
1313
- */}
1314
- <div className="steps-breadcrumb-strip">
1315
- {steps.length ? (
1316
- steps.map((stepItem, stepIndex) => {
1317
- const isActiveBreadcrumb = stepIndex === activeStep;
1318
- const isCompletedBreadcrumb = stepIndex < activeStep;
1319
-
1320
- return (
1321
- <button
1322
- key={stepItem.step_id || `${stepItem.step_name || 'step'}_${stepIndex}`}
1323
- type="button"
1324
- className={`steps-breadcrumb-item${isActiveBreadcrumb ? ' active' : ''}${isCompletedBreadcrumb ? ' completed' : ''}`}
1325
- onClick={() => handleTimelineClick(stepIndex)}
1326
- >
1327
- <span className="steps-breadcrumb-index">{stepIndex + 1}</span>
1328
- <span className="steps-breadcrumb-label">{stepItem.step_name || `Step ${stepIndex + 1}`}</span>
1329
- </button>
1330
- );
1331
- })
1332
- ) : (
1333
- <span className="steps-breadcrumb-empty">No steps loaded</span>
1334
- )}
1335
- </div>
1336
-
1337
- <div className="steps-nav-actions">
1338
- {/*
1339
- Previous-process button.
1340
- - Only relevant at the start of a process (`activeStep === 0`)
1341
- AND when the backend signalled a `previous_process_id`. Mid-
1342
- process the in-step Back button handles intra-process
1343
- navigation, so showing this here would be ambiguous.
1344
- */}
1345
- {activeStep === 0 && previousProcessId?.previous_process_id && (
1346
- <Button type="default" icon={<ArrowLeftOutlined />} onClick={handleStartPreviousProcess}>
1347
- {previousProcessId.previous_process_name ? `Back to ${previousProcessId.previous_process_name}` : 'Previous Process'}
1348
- </Button>
1349
- )}
1350
-
1351
- {activeStep > 0 && (
1352
- <Button
1353
- type="default"
1354
- icon={<ArrowLeftOutlined />}
1355
- onClick={handlePrevious}
1356
- aria-label="Back"
1357
- title="Back"
1358
- >
1359
- Back
1360
- </Button>
1361
- )}
1362
-
1363
- {/* {activeStep > 0 && !isFinalStep && (
1364
- <Button type="default" onClick={handleSkip}>
1365
- Skip
1366
- </Button>
1367
- )} */}
1368
-
1369
- {isFinalStep ? (
1370
- <>
1371
- {!showNextProcessAction && (
1372
- <Button
1373
- type="primary"
1374
- onClick={async () => {
1375
- const success = await handleFinish();
1376
- if (success && nextProcessId?.next_process_id) {
1377
- setShowNextProcessAction(true);
1378
- }
1379
- }}
1380
- >
1381
- Finish
1382
- </Button>
1383
- )}
1384
- {showNextProcessAction && nextProcessId?.next_process_id && (
1385
- <Button type="primary" onClick={handleStartNextProcess}>
1386
- Start {nextProcessId.next_process_name} <ArrowRightOutlined />
1387
- </Button>
1388
- )}
1389
- </>
1390
- ) : (
1391
- <Button
1392
- type="primary"
1393
- disabled={!isStepCompleted}
1394
- onClick={handleNext}
1395
- aria-label={activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}
1396
- title={activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}
1397
- >
1398
- {/*
1399
- First-step label is resolved via FIRST_STEP_LABELS using
1400
- the process name (lowercased + trimmed) as the key. Known
1401
- processes get a tailored CTA (e.g. "Verify Profile",
1402
- "Start Consultation"); unknown processes fall back to the
1403
- generic "Next" label. All non-first steps always render
1404
- "Next".
1405
- */}
1406
- {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'} <ArrowRightOutlined />
1407
- </Button>
1408
- )}
1409
- </div>
1410
- </div>
1411
-
1412
- {/*
1413
- Resume banner.
1414
- - Renders only when a saved active step is ahead of the current
1415
- position, so it stays out of the way during normal navigation
1416
- and only surfaces when the user returns after an unexpected
1417
- exit (refresh, tab close, navigation away).
1418
- - Resuming clamps to the current step range; dismissing clears
1419
- the persisted marker so the banner does not return for this
1420
- process.
1421
- */}
1422
- {resumableStep != null && resumableStep > activeStep && resumableStep < steps.length ? (
1423
- <div className="steps-resume-banner" role="status">
1424
- <span className="steps-resume-banner-text">
1425
- You left at Step {resumableStep + 1}
1426
- {steps[resumableStep]?.step_name ? ` — ${steps[resumableStep].step_name}` : ''}.
1427
- </span>
1428
- <div className="steps-resume-banner-actions">
1429
- <Button type="primary" size="small" onClick={handleResume}>
1430
- Resume
1431
- </Button>
1432
- <Button type="text" size="small" onClick={dismissResume}>
1433
- Dismiss
1434
- </Button>
1435
- </div>
1436
- </div>
1437
- ) : null}
1438
-
1439
- <div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1440
- {/*
1441
- Stage body:
1442
- - `is-swipe-enabled` applies `touch-action: pan-y` so horizontal
1443
- gestures reach our handlers while vertical scrolling remains
1444
- native.
1445
- - Touch handlers are only attached on touch devices to keep
1446
- desktop event trees untouched.
1447
- */}
1448
- <div
1449
- className={`steps-stage-body${isTouchDevice ? ' is-swipe-enabled' : ''}`}
1450
- onTouchStart={isTouchDevice ? handleStageTouchStart : undefined}
1451
- onTouchEnd={isTouchDevice ? handleStageTouchEnd : undefined}
1452
- >
1453
- {/*
1454
- Floating prev/next arrow buttons.
1455
- - Rendered only on touch devices; `is-visible` class drives
1456
- the fade-in/out via CSS transitions.
1457
- - Disabled states mirror the visible Next/Back buttons in the
1458
- top bar: previous disabled on the first step; next disabled
1459
- on the last/final step or when the current step still
1460
- requires user completion (isStepCompleted === false).
1461
- - Clicking either button reveals the arrows again so the
1462
- auto-hide timer restarts after every interaction.
1463
- */}
1464
- {isTouchDevice ? (
1465
- <>
1466
- <button
1467
- type="button"
1468
- className={`steps-touch-nav steps-touch-nav-left${touchNavVisible ? ' is-visible' : ''}`}
1469
- aria-label="Previous step"
1470
- disabled={activeStep === 0}
1471
- onClick={() => {
1472
- revealTouchNav();
1473
- if (activeStep > 0) handlePrevious();
1474
- }}
1475
- >
1476
- <ArrowLeftOutlined />
1477
- </button>
1478
- <button
1479
- type="button"
1480
- className={`steps-touch-nav steps-touch-nav-right${touchNavVisible ? ' is-visible' : ''}`}
1481
- aria-label="Next step"
1482
- disabled={activeStep >= steps.length - 1 || steps[activeStep]?.order_seqtype === 'E' || !isStepCompleted}
1483
- onClick={() => {
1484
- revealTouchNav();
1485
- const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1486
- if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1487
- handleNext();
1488
- }
1489
- }}
1490
- >
1491
- <ArrowRightOutlined />
1492
- </button>
1493
- </>
1494
- ) : null}
1495
- {/* <div
1496
- key={`${currentProcessId}_${activeStep}`}
1497
- className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
1498
- > */}
1499
- {/* <div className="steps-chat-step-top">
1500
- <span className="steps-index-pill">
1501
- Step {Math.min(activeStep + 1, steps.length || 1)} of {steps.length || 1}
1502
- </span>
1503
- <h2 className="steps-title">{currentStep?.step_name || 'No step selected'}</h2>
1504
- {currentStep?.step_description ? <p className="steps-description">{currentStep.step_description}</p> : null}
1505
- </div> */}
1506
-
1507
- {/* <div className="steps-chat-step-component"> */}
1508
- {loading ? (
1509
- <div className="steps-chat-loading">
1510
- <div className="steps-chat-loading__card">
1511
- <Skeleton active title={{ width: '32%' }} paragraph={{ rows: 6, width: ['90%', '82%', '86%', '70%', '78%', '60%'] }} />
1512
- </div>
1513
- </div>
1514
- ) : null}
1515
- {!loading ? renderDynamicStep() : null}
1516
- {/* </div> */}
1517
- {/* </div> */}
1518
- </div>
1519
- </div>
1520
-
1521
- {NARRATION_CONTROLS_ENABLED ? (
1522
- <div className="steps-bottom-nav steps-narration-bar">
1523
- <Select
1524
- className="steps-voice-provider-select"
1525
- value={voiceProvider}
1526
- options={VOICE_PROVIDER_OPTIONS}
1527
- onChange={(value) => setVoiceProvider(value)}
1528
- />
1529
- <Select
1530
- className="steps-voice-select"
1531
- value={currentVoiceValue}
1532
- options={currentVoiceOptions}
1533
- onChange={(value) =>
1534
- setVoiceSelections((oldSelections) => ({
1535
- ...oldSelections,
1536
- [voiceProvider]: value,
1537
- }))
1538
- }
1539
- placeholder="Select Voice"
1540
- optionFilterProp="label"
1541
- showSearch
1542
- disabled={!currentVoiceOptions.length}
1543
- />
1544
- <Button icon={<SoundOutlined />} onClick={speakCurrentStep} disabled={!currentStep}>
1545
- Read Step
1546
- </Button>
1547
- <Button
1548
- type={realtimeStatus === 'connected' ? 'default' : 'primary'}
1549
- disabled={!canStartRealtime}
1550
- onClick={() => {
1551
- if (realtimeStatus === 'connected' || realtimeStatus === 'connecting') {
1552
- stopRealtimeConversation();
1553
- return;
1554
- }
1555
- startRealtimeConversation();
1556
- }}
1557
- >
1558
- {realtimeStatus === 'connected' ? 'Stop Conversation' : realtimeStatus === 'connecting' ? 'Connecting...' : 'Start Conversation'}
1559
- </Button>
1560
- <Button onClick={() => setAutoNarration((oldValue) => !oldValue)}>Auto Narration: {autoNarration ? 'On' : 'Off'}</Button>
1561
- </div>
1562
- ) : null}
1563
- </Card>
1564
- </div>
1565
- </div>
1566
- );
1567
- };
1568
- /**
1569
- * Renders content in both the main window and an external window
1570
- * when external window mode is enabled.
1571
- */
1572
- if (showExternalWindow && props.showExternalWindow) {
1573
- return (
1574
- <>
1575
- <ExternalWindow
1576
- onWindowReady={(win) => {
1577
- setExternalWin(win);
1578
- win.focus();
1579
- }}
1580
- title={steps[activeStep]?.step_name || 'Process Step'}
1581
- onClose={() => setShowExternalWindow(false)}
1582
- // left={window.screenX + window.outerWidth}
1583
- // top={window.screenY}
1584
- width={props.ExternalWindowWidth || 1000}
1585
- height={props.ExternalWindowHeight || 1000}
1586
- >
1587
- {renderContent(false)}
1588
- </ExternalWindow>
1589
- {renderContent(true)}
1590
- </>
1591
- );
1592
- }
1593
- /**
1594
- * Default render when external window mode is disabled.
1595
- */
1596
- return renderContent();
1597
- }
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, Skeleton, 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${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1294
+ {/*
1295
+ Full-screen toggle is anchored to the far left of the bar so the
1296
+ step tabs can stretch across the middle and the primary CTA sits
1297
+ flush right (see design).
1298
+ */}
1299
+ <Button
1300
+ className="steps-fullscreen-toggle"
1301
+ type="dashed"
1302
+ icon={isStepFullscreen ? <CompressOutlined /> : <ExpandOutlined />}
1303
+ onClick={toggleStepFullscreen}
1304
+ aria-label={isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1305
+ title={isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1306
+ >
1307
+ {isStepFullscreen ? 'Exit Full Screen' : 'Full Screen'}
1308
+ </Button>
1309
+
1310
+ {/*
1311
+ Breadcrumb strip stays visible in fullscreen mode too so the
1312
+ user keeps the step timeline / navigation while presenting.
1313
+ */}
1314
+ <div className="steps-breadcrumb-strip">
1315
+ {steps.length ? (
1316
+ steps.map((stepItem, stepIndex) => {
1317
+ const isActiveBreadcrumb = stepIndex === activeStep;
1318
+ const isCompletedBreadcrumb = stepIndex < activeStep;
1319
+
1320
+ return (
1321
+ <button
1322
+ key={stepItem.step_id || `${stepItem.step_name || 'step'}_${stepIndex}`}
1323
+ type="button"
1324
+ className={`steps-breadcrumb-item${isActiveBreadcrumb ? ' active' : ''}${isCompletedBreadcrumb ? ' completed' : ''}`}
1325
+ onClick={() => handleTimelineClick(stepIndex)}
1326
+ >
1327
+ <span className="steps-breadcrumb-index">{stepIndex + 1}</span>
1328
+ <span className="steps-breadcrumb-label">{stepItem.step_name || `Step ${stepIndex + 1}`}</span>
1329
+ </button>
1330
+ );
1331
+ })
1332
+ ) : (
1333
+ <span className="steps-breadcrumb-empty">No steps loaded</span>
1334
+ )}
1335
+ </div>
1336
+
1337
+ <div className="steps-nav-actions">
1338
+ {/*
1339
+ Previous-process button.
1340
+ - Only relevant at the start of a process (`activeStep === 0`)
1341
+ AND when the backend signalled a `previous_process_id`. Mid-
1342
+ process the in-step Back button handles intra-process
1343
+ navigation, so showing this here would be ambiguous.
1344
+ */}
1345
+ {activeStep === 0 && previousProcessId?.previous_process_id && (
1346
+ <Button type="default" icon={<ArrowLeftOutlined />} onClick={handleStartPreviousProcess}>
1347
+ {previousProcessId.previous_process_name ? `Back to ${previousProcessId.previous_process_name}` : 'Previous Process'}
1348
+ </Button>
1349
+ )}
1350
+
1351
+ {activeStep > 0 && (
1352
+ <Button
1353
+ type="default"
1354
+ icon={<ArrowLeftOutlined />}
1355
+ onClick={handlePrevious}
1356
+ aria-label="Back"
1357
+ title="Back"
1358
+ >
1359
+ Back
1360
+ </Button>
1361
+ )}
1362
+
1363
+ {/* {activeStep > 0 && !isFinalStep && (
1364
+ <Button type="default" onClick={handleSkip}>
1365
+ Skip
1366
+ </Button>
1367
+ )} */}
1368
+
1369
+ {isFinalStep ? (
1370
+ <>
1371
+ {!showNextProcessAction && (
1372
+ <Button
1373
+ type="primary"
1374
+ onClick={async () => {
1375
+ const success = await handleFinish();
1376
+ if (success && nextProcessId?.next_process_id) {
1377
+ setShowNextProcessAction(true);
1378
+ }
1379
+ }}
1380
+ >
1381
+ Finish
1382
+ </Button>
1383
+ )}
1384
+ {showNextProcessAction && nextProcessId?.next_process_id && (
1385
+ <Button type="primary" onClick={handleStartNextProcess}>
1386
+ Start {nextProcessId.next_process_name} <ArrowRightOutlined />
1387
+ </Button>
1388
+ )}
1389
+ </>
1390
+ ) : (
1391
+ <Button
1392
+ type="primary"
1393
+ disabled={!isStepCompleted}
1394
+ onClick={handleNext}
1395
+ aria-label={activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}
1396
+ title={activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'}
1397
+ >
1398
+ {/*
1399
+ First-step label is resolved via FIRST_STEP_LABELS using
1400
+ the process name (lowercased + trimmed) as the key. Known
1401
+ processes get a tailored CTA (e.g. "Verify Profile",
1402
+ "Start Consultation"); unknown processes fall back to the
1403
+ generic "Next" label. All non-first steps always render
1404
+ "Next".
1405
+ */}
1406
+ {activeStep === 0 ? (FIRST_STEP_LABELS[processName?.trim().toLowerCase()] ?? 'Next') : 'Next'} <ArrowRightOutlined />
1407
+ </Button>
1408
+ )}
1409
+ </div>
1410
+ </div>
1411
+
1412
+ {/*
1413
+ Resume banner.
1414
+ - Renders only when a saved active step is ahead of the current
1415
+ position, so it stays out of the way during normal navigation
1416
+ and only surfaces when the user returns after an unexpected
1417
+ exit (refresh, tab close, navigation away).
1418
+ - Resuming clamps to the current step range; dismissing clears
1419
+ the persisted marker so the banner does not return for this
1420
+ process.
1421
+ */}
1422
+ {resumableStep != null && resumableStep > activeStep && resumableStep < steps.length ? (
1423
+ <div className="steps-resume-banner" role="status">
1424
+ <span className="steps-resume-banner-text">
1425
+ You left at Step {resumableStep + 1}
1426
+ {steps[resumableStep]?.step_name ? ` — ${steps[resumableStep].step_name}` : ''}.
1427
+ </span>
1428
+ <div className="steps-resume-banner-actions">
1429
+ <Button type="primary" size="small" onClick={handleResume}>
1430
+ Resume
1431
+ </Button>
1432
+ <Button type="text" size="small" onClick={dismissResume}>
1433
+ Dismiss
1434
+ </Button>
1435
+ </div>
1436
+ </div>
1437
+ ) : null}
1438
+
1439
+ <div className={`steps-content-panel${isStepFullscreen ? ' is-fullscreen' : ''}`}>
1440
+ {/*
1441
+ Stage body:
1442
+ - `is-swipe-enabled` applies `touch-action: pan-y` so horizontal
1443
+ gestures reach our handlers while vertical scrolling remains
1444
+ native.
1445
+ - Touch handlers are only attached on touch devices to keep
1446
+ desktop event trees untouched.
1447
+ */}
1448
+ <div
1449
+ className={`steps-stage-body${isTouchDevice ? ' is-swipe-enabled' : ''}`}
1450
+ onTouchStart={isTouchDevice ? handleStageTouchStart : undefined}
1451
+ onTouchEnd={isTouchDevice ? handleStageTouchEnd : undefined}
1452
+ >
1453
+ {/*
1454
+ Floating prev/next arrow buttons.
1455
+ - Rendered only on touch devices; `is-visible` class drives
1456
+ the fade-in/out via CSS transitions.
1457
+ - Disabled states mirror the visible Next/Back buttons in the
1458
+ top bar: previous disabled on the first step; next disabled
1459
+ on the last/final step or when the current step still
1460
+ requires user completion (isStepCompleted === false).
1461
+ - Clicking either button reveals the arrows again so the
1462
+ auto-hide timer restarts after every interaction.
1463
+ */}
1464
+ {isTouchDevice ? (
1465
+ <>
1466
+ <button
1467
+ type="button"
1468
+ className={`steps-touch-nav steps-touch-nav-left${touchNavVisible ? ' is-visible' : ''}`}
1469
+ aria-label="Previous step"
1470
+ disabled={activeStep === 0}
1471
+ onClick={() => {
1472
+ revealTouchNav();
1473
+ if (activeStep > 0) handlePrevious();
1474
+ }}
1475
+ >
1476
+ <ArrowLeftOutlined />
1477
+ </button>
1478
+ <button
1479
+ type="button"
1480
+ className={`steps-touch-nav steps-touch-nav-right${touchNavVisible ? ' is-visible' : ''}`}
1481
+ aria-label="Next step"
1482
+ disabled={activeStep >= steps.length - 1 || steps[activeStep]?.order_seqtype === 'E' || !isStepCompleted}
1483
+ onClick={() => {
1484
+ revealTouchNav();
1485
+ const isFinalStep = steps[activeStep]?.order_seqtype === 'E';
1486
+ if (!isFinalStep && isStepCompleted && activeStep < steps.length - 1) {
1487
+ handleNext();
1488
+ }
1489
+ }}
1490
+ >
1491
+ <ArrowRightOutlined />
1492
+ </button>
1493
+ </>
1494
+ ) : null}
1495
+ {/* <div
1496
+ key={`${currentProcessId}_${activeStep}`}
1497
+ className={`steps-chat-step-card ${stepSlideDirection === 'backward' ? 'slide-backward' : 'slide-forward'}`}
1498
+ > */}
1499
+ {/* <div className="steps-chat-step-top">
1500
+ <span className="steps-index-pill">
1501
+ Step {Math.min(activeStep + 1, steps.length || 1)} of {steps.length || 1}
1502
+ </span>
1503
+ <h2 className="steps-title">{currentStep?.step_name || 'No step selected'}</h2>
1504
+ {currentStep?.step_description ? <p className="steps-description">{currentStep.step_description}</p> : null}
1505
+ </div> */}
1506
+
1507
+ {/* <div className="steps-chat-step-component"> */}
1508
+ {loading ? (
1509
+ <div className="steps-chat-loading">
1510
+ <div className="steps-chat-loading__card">
1511
+ <Skeleton active title={{ width: '32%' }} paragraph={{ rows: 6, width: ['90%', '82%', '86%', '70%', '78%', '60%'] }} />
1512
+ </div>
1513
+ </div>
1514
+ ) : null}
1515
+ {!loading ? renderDynamicStep() : null}
1516
+ {/* </div> */}
1517
+ {/* </div> */}
1518
+ </div>
1519
+ </div>
1520
+
1521
+ {NARRATION_CONTROLS_ENABLED ? (
1522
+ <div className="steps-bottom-nav steps-narration-bar">
1523
+ <Select
1524
+ className="steps-voice-provider-select"
1525
+ value={voiceProvider}
1526
+ options={VOICE_PROVIDER_OPTIONS}
1527
+ onChange={(value) => setVoiceProvider(value)}
1528
+ />
1529
+ <Select
1530
+ className="steps-voice-select"
1531
+ value={currentVoiceValue}
1532
+ options={currentVoiceOptions}
1533
+ onChange={(value) =>
1534
+ setVoiceSelections((oldSelections) => ({
1535
+ ...oldSelections,
1536
+ [voiceProvider]: value,
1537
+ }))
1538
+ }
1539
+ placeholder="Select Voice"
1540
+ optionFilterProp="label"
1541
+ showSearch
1542
+ disabled={!currentVoiceOptions.length}
1543
+ />
1544
+ <Button icon={<SoundOutlined />} onClick={speakCurrentStep} disabled={!currentStep}>
1545
+ Read Step
1546
+ </Button>
1547
+ <Button
1548
+ type={realtimeStatus === 'connected' ? 'default' : 'primary'}
1549
+ disabled={!canStartRealtime}
1550
+ onClick={() => {
1551
+ if (realtimeStatus === 'connected' || realtimeStatus === 'connecting') {
1552
+ stopRealtimeConversation();
1553
+ return;
1554
+ }
1555
+ startRealtimeConversation();
1556
+ }}
1557
+ >
1558
+ {realtimeStatus === 'connected' ? 'Stop Conversation' : realtimeStatus === 'connecting' ? 'Connecting...' : 'Start Conversation'}
1559
+ </Button>
1560
+ <Button onClick={() => setAutoNarration((oldValue) => !oldValue)}>Auto Narration: {autoNarration ? 'On' : 'Off'}</Button>
1561
+ </div>
1562
+ ) : null}
1563
+ </Card>
1564
+ </div>
1565
+ </div>
1566
+ );
1567
+ };
1568
+ /**
1569
+ * Renders content in both the main window and an external window
1570
+ * when external window mode is enabled.
1571
+ */
1572
+ if (showExternalWindow && props.showExternalWindow) {
1573
+ return (
1574
+ <>
1575
+ <ExternalWindow
1576
+ onWindowReady={(win) => {
1577
+ setExternalWin(win);
1578
+ win.focus();
1579
+ }}
1580
+ title={steps[activeStep]?.step_name || 'Process Step'}
1581
+ onClose={() => setShowExternalWindow(false)}
1582
+ // left={window.screenX + window.outerWidth}
1583
+ // top={window.screenY}
1584
+ width={props.ExternalWindowWidth || 1000}
1585
+ height={props.ExternalWindowHeight || 1000}
1586
+ >
1587
+ {renderContent(false)}
1588
+ </ExternalWindow>
1589
+ {renderContent(true)}
1590
+ </>
1591
+ );
1592
+ }
1593
+ /**
1594
+ * Default render when external window mode is disabled.
1595
+ */
1596
+ return renderContent();
1597
+ }