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