feat: add agora session

This commit is contained in:
SD 2024-06-21 19:25:31 +04:00
parent 0828e944b4
commit 6c875cdf39
38 changed files with 1739 additions and 386 deletions

284
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@ant-design/icons": "^5.2.6",
"@ant-design/nextjs-registry": "^1.0.0",
"agora-rtc-react": "^2.1.0",
"agora-rtc-sdk-ng": "^4.20.2",
"antd": "^5.12.1",
"antd-img-crop": "^4.21.0",
"axios": "^1.6.5",
@ -49,6 +50,37 @@
"node": ">=0.10.0"
}
},
"node_modules/@agora-js/media": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/@agora-js/media/-/media-4.20.2.tgz",
"integrity": "sha512-JLZ2faGwKxBjEhCG+LTsfj1n54+TKhV7cTwJdS3NFxNYM2+pVmBSvRR0LG6cidza1dbKkvd4/UcZUT1pvqkulg==",
"dependencies": {
"@agora-js/report": "4.20.2",
"@agora-js/shared": "4.20.2",
"agora-rte-extension": "^1.2.4",
"axios": "^1.6.7",
"pako": "^2.1.0",
"webrtc-adapter": "8.2.0"
}
},
"node_modules/@agora-js/report": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/@agora-js/report/-/report-4.20.2.tgz",
"integrity": "sha512-uKaZkLNgzRBExwqB58plN704NgDkz0kfQJwopcVcfnYk/kEN0H1qwxNtoIXHtO8FBkBI2RD5506KL8Afj7FrOQ==",
"dependencies": {
"@agora-js/shared": "4.20.2",
"axios": "^1.6.7"
}
},
"node_modules/@agora-js/shared": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/@agora-js/shared/-/shared-4.20.2.tgz",
"integrity": "sha512-vm5PtWSgbrNmH/RWQ6WfV7g/JCnjtMGc9qvnqMCRItQA1CIgdhNKtW4eH6uzZn3D6xCFS33WjWUkQ/VGo7NPnA==",
"dependencies": {
"axios": "^1.6.7",
"ua-parser-js": "^0.7.34"
}
},
"node_modules/@ant-design/colors": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.2.tgz",
@ -896,6 +928,26 @@
"react": ">=16.8"
}
},
"node_modules/agora-rtc-sdk-ng": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/agora-rtc-sdk-ng/-/agora-rtc-sdk-ng-4.20.2.tgz",
"integrity": "sha512-1AFFfdSdzMu4XRV6JIg5K8oKFWSOUnwcpTGdscsXXI/cfEJMuOGvW7doeEqTWiwBkbinLOrYleotBoBfZMYNDA==",
"dependencies": {
"@agora-js/media": "4.20.2",
"@agora-js/report": "4.20.2",
"@agora-js/shared": "4.20.2",
"agora-rte-extension": "^1.2.4",
"axios": "^1.6.7",
"formdata-polyfill": "^4.0.7",
"ua-parser-js": "^0.7.34",
"webrtc-adapter": "8.2.0"
}
},
"node_modules/agora-rte-extension": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/agora-rte-extension/-/agora-rte-extension-1.2.4.tgz",
"integrity": "sha512-0ovZz1lbe30QraG1cU+ji7EnQ8aUu+Hf3F+a8xPml3wPOyUQEK6CTdxV9kMecr9t+fIDrGeW7wgJTsM1DQE7Nw=="
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -1256,11 +1308,11 @@
}
},
"node_modules/axios": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz",
"integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"dependencies": {
"follow-redirects": "^1.15.4",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@ -2296,6 +2348,28 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -2357,9 +2431,9 @@
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz",
"integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==",
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"funding": [
{
"type": "individual",
@ -2397,6 +2471,17 @@
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -3483,6 +3568,24 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-releases": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
@ -3686,6 +3789,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4698,6 +4806,11 @@
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw=="
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@ -5172,6 +5285,28 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "0.7.38",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.38.tgz",
"integrity": "sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"engines": {
"node": "*"
}
},
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@ -5256,6 +5391,26 @@
"node": ">=10.13.0"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"engines": {
"node": ">= 8"
}
},
"node_modules/webrtc-adapter": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.0.tgz",
"integrity": "sha512-umxCMgedPAVq4Pe/jl3xmelLXLn4XZWFEMR5Iipb5wJ+k1xMX0yC4ZY9CueZUU1MjapFxai1tFGE7R/kotH6Ww==",
"dependencies": {
"sdp": "^3.0.2"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -5379,6 +5534,37 @@
"integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
"dev": true
},
"@agora-js/media": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/@agora-js/media/-/media-4.20.2.tgz",
"integrity": "sha512-JLZ2faGwKxBjEhCG+LTsfj1n54+TKhV7cTwJdS3NFxNYM2+pVmBSvRR0LG6cidza1dbKkvd4/UcZUT1pvqkulg==",
"requires": {
"@agora-js/report": "4.20.2",
"@agora-js/shared": "4.20.2",
"agora-rte-extension": "^1.2.4",
"axios": "^1.6.7",
"pako": "^2.1.0",
"webrtc-adapter": "8.2.0"
}
},
"@agora-js/report": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/@agora-js/report/-/report-4.20.2.tgz",
"integrity": "sha512-uKaZkLNgzRBExwqB58plN704NgDkz0kfQJwopcVcfnYk/kEN0H1qwxNtoIXHtO8FBkBI2RD5506KL8Afj7FrOQ==",
"requires": {
"@agora-js/shared": "4.20.2",
"axios": "^1.6.7"
}
},
"@agora-js/shared": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/@agora-js/shared/-/shared-4.20.2.tgz",
"integrity": "sha512-vm5PtWSgbrNmH/RWQ6WfV7g/JCnjtMGc9qvnqMCRItQA1CIgdhNKtW4eH6uzZn3D6xCFS33WjWUkQ/VGo7NPnA==",
"requires": {
"axios": "^1.6.7",
"ua-parser-js": "^0.7.34"
}
},
"@ant-design/colors": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.2.tgz",
@ -5981,6 +6167,26 @@
"integrity": "sha512-3FGteA7FG51oK5MusbYNgAcKZaAQK+4sbEz4F0DPzcpDxqNANpocJDqOsmXoUAj5yDBsBZelmagU3abd++6RGA==",
"requires": {}
},
"agora-rtc-sdk-ng": {
"version": "4.20.2",
"resolved": "https://registry.npmjs.org/agora-rtc-sdk-ng/-/agora-rtc-sdk-ng-4.20.2.tgz",
"integrity": "sha512-1AFFfdSdzMu4XRV6JIg5K8oKFWSOUnwcpTGdscsXXI/cfEJMuOGvW7doeEqTWiwBkbinLOrYleotBoBfZMYNDA==",
"requires": {
"@agora-js/media": "4.20.2",
"@agora-js/report": "4.20.2",
"@agora-js/shared": "4.20.2",
"agora-rte-extension": "^1.2.4",
"axios": "^1.6.7",
"formdata-polyfill": "^4.0.7",
"ua-parser-js": "^0.7.34",
"webrtc-adapter": "8.2.0"
}
},
"agora-rte-extension": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/agora-rte-extension/-/agora-rte-extension-1.2.4.tgz",
"integrity": "sha512-0ovZz1lbe30QraG1cU+ji7EnQ8aUu+Hf3F+a8xPml3wPOyUQEK6CTdxV9kMecr9t+fIDrGeW7wgJTsM1DQE7Nw=="
},
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -6244,11 +6450,11 @@
"dev": true
},
"axios": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz",
"integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"requires": {
"follow-redirects": "^1.15.4",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@ -7043,6 +7249,15 @@
"reusify": "^1.0.4"
}
},
"fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"requires": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
}
},
"file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -7089,9 +7304,9 @@
"dev": true
},
"follow-redirects": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz",
"integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw=="
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA=="
},
"for-each": {
"version": "0.3.3",
@ -7112,6 +7327,14 @@
"mime-types": "^2.1.12"
}
},
"formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"requires": {
"fetch-blob": "^3.1.2"
}
},
"fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -7884,6 +8107,11 @@
"use-intl": "^3.3.1"
}
},
"node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
},
"node-releases": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
@ -8033,6 +8261,11 @@
"p-limit": "^3.0.2"
}
},
"pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
},
"parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -8722,6 +8955,11 @@
"compute-scroll-into-view": "^3.0.2"
}
},
"sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw=="
},
"semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@ -9057,6 +9295,11 @@
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
"dev": true
},
"ua-parser-js": {
"version": "0.7.38",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.38.tgz",
"integrity": "sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA=="
},
"unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@ -9112,6 +9355,19 @@
"graceful-fs": "^4.1.2"
}
},
"web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
},
"webrtc-adapter": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.0.tgz",
"integrity": "sha512-umxCMgedPAVq4Pe/jl3xmelLXLn4XZWFEMR5Iipb5wJ+k1xMX0yC4ZY9CueZUU1MjapFxai1tFGE7R/kotH6Ww==",
"requires": {
"sdp": "^3.0.2"
}
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -13,6 +13,7 @@
"@ant-design/icons": "^5.2.6",
"@ant-design/nextjs-registry": "^1.0.0",
"agora-rtc-react": "^2.1.0",
"agora-rtc-sdk-ng": "^4.20.2",
"antd": "^5.12.1",
"antd-img-crop": "^4.21.0",
"axios": "^1.6.5",

View File

@ -0,0 +1,42 @@
'use client'
import { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY } from '../../constants/common';
import { Session } from '../../types/sessions';
import { getSessionDetails } from '../sessions';
export const useSessionDetails = (locale: string, sessionId: number) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [session, setSession] = useState<Session>();
const [errorData, setErrorData] = useState<any>();
const [loading, setLoading] = useState<boolean>(false);
const fetchData = useCallback(() => {
setLoading(true);
setErrorData(undefined);
setSession(undefined);
getSessionDetails(locale, jwt, sessionId)
.then(({ data }) => {
setSession(data);
})
.catch((err) => {
setErrorData(err);
})
.finally(() => {
setLoading(false);
})
}, []);
useEffect(() => {
fetchData();
}, []);
return {
fetchData,
loading,
session,
errorData
};
};

View File

@ -0,0 +1,46 @@
'use client'
import { useCallback, useEffect, useRef } from 'react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY } from '../../constants/common';
import { trackingStartSession } from '../sessions';
const DURATION = 30000;
export const useSessionTracking = (locale: string, sessionId: number) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const timer = useRef<number>();
const fetchData = useCallback(() => {
trackingStartSession(locale, jwt, sessionId)
.then(() => {
console.log('tracking success');
})
.catch((err) => {
console.log('tracking error', err);
})
}, []);
useEffect(() => {
return () => {
window.clearInterval(timer.current);
}
}, []);
const start = () => {
window.clearInterval(timer.current);
timer.current = window.setInterval(() => {
fetchData();
}, DURATION);
};
const stop = () => {
window.clearInterval(timer.current);
};
return {
start,
stop
};
};

View File

@ -117,3 +117,29 @@ export const addSessionComment = (locale: string, jwt: string, data: SessionComm
}
)
);
export const trackingStartSession = (locale: string, jwt: string, id: number): Promise<AxiosResponse> => (
apiClient.post(
'/home/sessiontracking',
{ id },
{
headers: {
'X-User-Language': locale,
Authorization: `Bearer ${jwt}`
}
}
)
);
export const finishSession = (locale: string, jwt: string, sessionId: number): Promise<AxiosResponse> => (
apiClient.post(
'/home/finishsession',
{ sessionId },
{
headers: {
'X-User-Language': locale,
Authorization: `Bearer ${jwt}`
}
}
)
);

View File

@ -2,34 +2,19 @@
import React, { ReactNode } from 'react';
import { AccountMenu } from '../../../../components/Account';
import { i18nText } from '../../../../i18nKeys';
type AccountInnerLayoutProps = {
children: ReactNode;
params: { locale: string };
};
const ROUTES = ['sessions', 'notifications', 'support', 'information', 'settings', 'messages', 'work-with-us'];
const COUNTS: Record<string, number> = {
sessions: 12,
notifications: 5,
messages: 113
};
export default function AccountInnerLayout({ children, params: { locale } }: AccountInnerLayoutProps) {
const getMenuConfig = () => ROUTES.map((path) => ({
path,
title: i18nText(`accountMenu.${path}`, locale),
count: COUNTS[path] || undefined
}));
return (
<div className="page-account">
<div className="b-inner">
<div className="row">
<div className="col-xl-3 col-lg-4 d-none d-lg-block">
<AccountMenu menu={getMenuConfig()} locale={locale} />
<AccountMenu locale={locale} />
</div>
<div className="col-xl-9 col-lg-8 ">
<div className="page-account__inner">

View File

@ -1,19 +0,0 @@
import React, { Suspense } from 'react';
import type { Metadata } from 'next';
import { useTranslations } from 'next-intl';
import { SessionsAll } from '../../../../../components/Account';
export const metadata: Metadata = {
title: 'Bbuddy - Account - Sessions',
description: 'Bbuddy desc sessions'
};
export default function Sessions({ params: { locale } }: { params: { locale: string } }) {
const t = useTranslations('Account.Sessions');
return (
<Suspense fallback={<p>Loading...</p>}>
<SessionsAll locale={locale} />
</Suspense>
);
}

View File

@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
type AccountSimpleLayoutProps = {
children: ReactNode;
};
export default function AccountSimpleLayout({ children }: AccountSimpleLayoutProps) {
return (
<div className="page-account">
<div className="b-inner">
<div className="row">
{children}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,55 @@
import React, { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { AccountMenu, SessionDetails, SessionsTabs } from '../../../../../../components/Account';
import { SessionType } from '../../../../../../types/sessions';
const SESSION_ROUTES = [SessionType.UPCOMING, SessionType.REQUESTED, SessionType.RECENT];
export async function generateStaticParams({
params: { locale },
}: { params: { locale: string } }) {
return [{ locale, slug: [SessionType.UPCOMING] }];
}
export default function SessionDetailItem({ params: { locale, slug } }: { params: { locale: string, slug?: string[] } }) {
const sessionType: string = slug?.length > 0 && slug[0] || '';
const sessionId: number | null = slug?.length > 1 && Number(slug[1]) || null;
if (!slug?.length || slug?.length > 2) {
notFound();
}
if (SESSION_ROUTES.includes(sessionType as SessionType) && Number.isInteger(sessionId)) {
return (
<Suspense fallback={<p>Loading...</p>}>
<SessionDetails
locale={locale}
sessionId={sessionId}
activeType={sessionType as SessionType}
/>
</Suspense>
);
}
if (SESSION_ROUTES.includes(sessionType as SessionType) && !Number.isInteger(sessionId)) {
return (
<>
<div className="col-xl-3 col-lg-4 d-none d-lg-block">
<AccountMenu locale={locale}/>
</div>
<div className="col-xl-9 col-lg-8 ">
<div className="page-account__inner">
<Suspense fallback={<p>Loading...</p>}>
<SessionsTabs
locale={locale}
activeTab={sessionType as SessionType}
/>
</Suspense>
</div>
</div>
</>
);
}
return notFound();
};

View File

@ -1,7 +1,6 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import { Button } from 'antd';
import { useSelectedLayoutSegment, usePathname } from 'next/navigation';
import { Link } from '../../navigation';
@ -9,23 +8,14 @@ import { AUTH_TOKEN_KEY, AUTH_USER } from '../../constants/common';
import { deleteStorageKey } from '../../hooks/useLocalStorage';
import { i18nText } from '../../i18nKeys';
import { DeleteAccountModal } from '../Modals/DeleteAccountModal';
import { getMenuConfig } from '../../utils/account';
const Logout = styled(Button)`
width: 100%;
height: 49px !important;
color: #D93E5C !important;
font-style: normal;
font-weight: 600 !important;
padding: 0 !important;
font-size: 1.125rem !important;
text-align: left !important;
`;
export const AccountMenu = ({ menu, locale }: { menu: { path: string, title: string, count?: number }[], locale: string }) => {
export const AccountMenu = ({ locale }: { locale: string }) => {
const selectedLayoutSegment = useSelectedLayoutSegment();
const pathname = selectedLayoutSegment || '';
const paths = usePathname();
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const menu: { path: string, title: string, count?: number }[] = getMenuConfig(locale);
const onLogout = () => {
deleteStorageKey(AUTH_TOKEN_KEY);
@ -48,20 +38,22 @@ export const AccountMenu = ({ menu, locale }: { menu: { path: string, title: str
</li>
))}
<li className="list-sidebar__item">
<Logout
<Button
type="link"
onClick={onLogout}
className="b-button__logout"
>
{i18nText('logout', locale)}
</Logout>
</Button>
</li>
<li className="list-sidebar__item">
<Logout
<Button
type="link"
onClick={onDeleteAccount}
className="b-button__logout"
>
{i18nText('deleteAcc', locale)}
</Logout>
</Button>
<DeleteAccountModal
open={showDeleteModal}
handleCancel={() => setShowDeleteModal(false)}

View File

@ -0,0 +1,61 @@
'use client'
import { useJoin } from 'agora-rtc-react';
import { useEffect, useState } from 'react';
import { MediaControl } from './view';
import { LocalUserPanel, RemoteUserPanel } from './components';
import { PublicUser } from '../../../types/sessions';
// const appId = process.env.AGORA_APPID;
type AgoraProps = {
sessionId: number;
secret?: string;
stopCalling: () => void;
remoteUser?: PublicUser;
};
export const Agora = ({ sessionId, secret, stopCalling, remoteUser }: AgoraProps) => {
const [calling, setCalling] = useState(false);
const [micOn, setMic] = useState(false);
const [cameraOn, setCamera] = useState(false);
useEffect(() => {
setCalling(true);
}, []);
useJoin(
{
appid: 'ed90c9dc42634e5687d4e2e0766b363f',
channel: `${sessionId}-${secret}`,
token: null,
},
calling,
);
const stop = () => {
stopCalling();
setCalling(false);
};
return (
<div className="b-agora__wrap">
<RemoteUserPanel calling={calling} user={remoteUser} />
<div className="b-agora__panel">
<MediaControl
calling={calling}
cameraOn={cameraOn}
micOn={micOn}
setCalling={stop}
setCamera={() => setCamera(a => !a)}
setMic={() => setMic(a => !a)}
/>
<LocalUserPanel
calling={calling}
cameraOn={cameraOn}
micOn={micOn}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,61 @@
import { LocalUser, useLocalMicrophoneTrack, useLocalCameraTrack, usePublish, useIsConnected } from 'agora-rtc-react';
import { useState, useEffect } from 'react';
import { UserOutlined } from '@ant-design/icons';
import { useLocalStorage } from '../../../../hooks/useLocalStorage';
import { AUTH_USER } from '../../../../constants/common';
type LocalUserPanelProps = {
calling: boolean;
micOn: boolean;
cameraOn: boolean;
}
export const LocalUserPanel = ({
calling,
micOn,
cameraOn
}: LocalUserPanelProps) => {
const isConnected = useIsConnected();
const [userData] = useLocalStorage(AUTH_USER, '');
const { faceImageUrl: userImage = '' } = userData ? JSON.parse(userData) : {};
const [playVideo, setPlayVideo] = useState(false);
const [playAudio, setPlayAudio] = useState(false);
const { localMicrophoneTrack } = useLocalMicrophoneTrack(micOn);
const { localCameraTrack } = useLocalCameraTrack(cameraOn);
usePublish([localMicrophoneTrack, localCameraTrack]);
useEffect(() => {
if (calling) {
setPlayVideo(cameraOn)
}
}, [cameraOn]);
useEffect(() => {
if (calling) {
setPlayAudio(micOn)
}
}, [micOn]);
return calling && isConnected ? (
<div className="b-agora__local_user">
{!cameraOn && (
<div className="b-agora__local_base">
<div className="b-agora__call_avatar" style={userImage ? { backgroundImage: `url(${userImage})` } : undefined}>
{!userImage && (<UserOutlined style={{ fontSize: 40, color: '#2C7873' }}/>)}
</div>
</div>
)}
<LocalUser
audioTrack={localMicrophoneTrack}
cameraOn={cameraOn}
micOn={micOn}
playAudio={playAudio}
playVideo={playVideo}
style={{ width: '100%', height: '100%' }}
videoTrack={localCameraTrack}
/>
</div>
) : null;
};

View File

@ -0,0 +1,36 @@
import { UserOutlined } from '@ant-design/icons';
import {
useIsConnected,
useRemoteAudioTracks,
useRemoteUsers,
useRemoteVideoTracks
} from 'agora-rtc-react';
import { RenderRemoteUsers } from './RemoteUsers';
import { PublicUser } from '../../../../types/sessions';
export const RemoteUserPanel = ({ calling, user }: { calling: boolean, user?: PublicUser }) => {
const isConnected = useIsConnected();
const remoteUsers = useRemoteUsers();
const { videoTracks } = useRemoteVideoTracks(remoteUsers);
const { audioTracks } = useRemoteAudioTracks(remoteUsers);
audioTracks.map(track => track.play());
return calling && isConnected ? (
<div className="b-agora__remote_user">
{videoTracks?.length > 0 ? (
<RenderRemoteUsers videoTracks={videoTracks} />
) : (
<div className="b-agora__remote_base">
{remoteUsers?.length === 0 && (
<div className="b-agora__remote_warning">
Ожидайте подключения собеседника
</div>
)}
<div className="b-agora__call_avatar" style={user?.faceImageUrl ? { backgroundImage: `url(${user.faceImageUrl})` } : undefined}>
{!user?.faceImageUrl && (<UserOutlined style={{ fontSize: 40, color: '#2C7873' }}/>)}
</div>
</div>
)}
</div>
) : null;
}

View File

@ -0,0 +1,12 @@
import type { IRemoteVideoTrack } from 'agora-rtc-react';
import { RemoteVideoPlayer } from './RemoteVideoPlayer';
export function RenderRemoteUsers({ videoTracks }: { videoTracks: IRemoteVideoTrack[] }) {
return (
<>
{videoTracks.map((track: IRemoteVideoTrack) => (
<RemoteVideoPlayer key={track.getUserId()} track={track} className="b-agora__video_remote" />
))}
</>
);
}

View File

@ -0,0 +1,69 @@
import type { HTMLProps, ReactNode } from 'react';
import type { IAgoraRTCClient, IRemoteVideoTrack } from 'agora-rtc-react';
import { RemoteVideoTrack, useRTCClient } from 'agora-rtc-react';
import { UserCover } from '../components';
export interface RemoteVideoPlayerProps extends HTMLProps<HTMLDivElement> {
/**
* A remote track
*/
readonly track?: IRemoteVideoTrack;
/**
* Whether to play the remote user's video track. Default follows `user.hasVideo`.
*/
readonly playVideo?: boolean;
/**
* Render cover image if playVideo is off.
*/
readonly cover?: string | (() => ReactNode);
/**
* Children is rendered on top of the video canvas.
*/
readonly children?: ReactNode;
/**
* client instance
*/
readonly client?: IAgoraRTCClient | null;
}
/**
* Subscribe and play remote user video track.
* An `IRemoteVideoTrack` can only be own by one `RemoteVideoPlayer`.
*/
export function RemoteVideoPlayer({
track,
playVideo,
cover,
client,
style,
children,
...props
}: RemoteVideoPlayerProps) {
const resolvedClient = useRTCClient(client);
const hasVideo = resolvedClient.remoteUsers?.find(
user => user.uid === track?.getUserId(),
)?.hasVideo;
playVideo = playVideo ?? hasVideo;
return (
<div {...props} style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
background: "#000",
...style
}}>
<RemoteVideoTrack play={playVideo} track={track} />
{cover && !playVideo && <UserCover cover={cover} />}
<div style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
overflow: "hidden",
zIndex: 2,
}}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
import type { CSSProperties, ReactNode } from 'react';
export const FloatBoxStyle: CSSProperties = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
overflow: "hidden",
zIndex: 2,
};
const CoverBlurStyle: CSSProperties = {
width: "100%",
height: "100%",
background: "#1a1e21 center/cover no-repeat",
filter: "blur(16px) brightness(0.4)",
};
const CoverImgStyle: CSSProperties = {
position: "absolute",
top: "50%",
left: "50%",
maxWidth: "50%",
maxHeight: "50%",
aspectRatio: "1",
transform: "translate(-50%, -50%)",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover",
};
export interface UserCoverProps {
/**
* Cover image url or a custom render function.
*/
cover: string | (() => ReactNode);
}
/**
* User Cover image with blur background
*/
export function UserCover({ cover }: UserCoverProps) {
return (
<div style={FloatBoxStyle}>
{typeof cover === "string" ? (
<>
<div style={{ ...CoverBlurStyle, backgroundImage: `url(${cover})` }} />
<img src={cover} style={CoverImgStyle} />
</>
) : (
cover()
)}
</div>
);
}

View File

@ -0,0 +1,5 @@
export * from './RemoteVideoPlayer';
export * from './UserCover';
export * from './RemoteUsers';
export * from './LocalUserPanel';
export * from './RemoteUserPanel';

View File

@ -0,0 +1,61 @@
'use client'
import React from 'react';
import Icon from '@ant-design/icons';
import type { GetProps } from 'antd';
type CustomIconComponentProps = GetProps<typeof Icon>;
const MicOnSvg = () => (
<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none">
<path d="M9 21H15M18 9.75V11.25C18 14.55 15.3 17.25 12 17.25C8.7 17.25 6 14.55 6 11.25V9.75M12 17.25V21" stroke="currentColor" strokeMiterlimit="10" strokeLinecap="square"/>
<path d="M12 15C11.505 14.9989 11.0153 14.8983 10.56 14.7042C10.1046 14.5102 9.6929 14.2266 9.34925 13.8703C8.64573 13.1608 8.2507 12.2023 8.25003 11.2032V6.00003C8.24811 5.50703 8.34379 5.01853 8.53157 4.5627C8.71934 4.10686 8.99549 3.6927 9.34409 3.34409C9.6927 2.99549 10.1069 2.71934 10.5627 2.53157C11.0185 2.34379 11.507 2.24811 12 2.25003C14.1028 2.25003 15.75 3.89722 15.75 6.00003V11.2032C15.75 13.2966 14.0677 15 12 15Z" fill="currentColor"/>
</svg>
);
const MicOffSvg = () => (
<svg width="1em" height="1em" viewBox="0 0 25 25" fill="none">
<path d="M10 22H16M19 10.75V12.25C19 15.55 16.3 18.25 13 18.25C9.7 18.25 7 15.55 7 12.25V10.75M13 18.25V22" stroke="currentColor" strokeMiterlimit="10" strokeLinecap="square"/>
<path d="M13 16C12.505 15.9989 12.0153 15.8983 11.56 15.7042C11.1046 15.5102 10.6929 15.2266 10.3492 14.8703C9.64573 14.1608 9.2507 13.2023 9.25003 12.2032V7.00003C9.24811 6.50703 9.34379 6.01853 9.53157 5.5627C9.71934 5.10686 9.99549 4.6927 10.3441 4.34409C10.6927 3.99549 11.1069 3.71934 11.5627 3.53157C12.0185 3.34379 12.507 3.24811 13 3.25003C15.1028 3.25003 16.75 4.89722 16.75 7.00003V12.2032C16.75 14.2966 15.0677 16 13 16Z" fill="currentColor"/>
<rect x="23.3135" y="0.476074" width="2" height="32" rx="1" transform="rotate(45 23.3135 0.476074)" fill="currentColor"/>
</svg>
);
const CameraOnSvg = () => (
<svg width="1em" height="1em" viewBox="0 0 24 14" fill="none">
<path d="M21.75 13.0183C21.54 13.0181 21.3324 12.9739 21.1406 12.8884C21.096 12.8686 21.0534 12.8444 21.0136 12.8162L17.1366 10.0872C16.9399 9.94878 16.7795 9.76513 16.6687 9.55172C16.5579 9.33831 16.5001 9.10139 16.5 8.86094V5.13906C16.5001 4.89861 16.5579 4.66169 16.6687 4.44828C16.7795 4.23487 16.9399 4.05122 17.1366 3.91281L21.0136 1.18375C21.0534 1.15555 21.096 1.13137 21.1406 1.11156C21.369 1.01003 21.6191 0.967168 21.8683 0.986876C22.1174 1.00658 22.3577 1.08824 22.5673 1.22441C22.7769 1.36059 22.9491 1.54697 23.0683 1.76663C23.1875 1.98629 23.25 2.23226 23.25 2.48219V11.5178C23.25 11.9156 23.092 12.2972 22.8107 12.5785C22.5294 12.8598 22.1478 13.0178 21.75 13.0178V13.0183ZM12.5625 13.75H3.9375C3.09239 13.7491 2.28214 13.413 1.68456 12.8154C1.08697 12.2179 0.750869 11.4076 0.75 10.5625V3.4375C0.750869 2.59239 1.08697 1.78214 1.68456 1.18456C2.28214 0.586973 3.09239 0.250869 3.9375 0.25H12.585C13.4241 0.250992 14.2286 0.584765 14.8219 1.1781C15.4152 1.77144 15.749 2.57589 15.75 3.415V10.5625C15.7491 11.4076 15.413 12.2179 14.8154 12.8154C14.2179 13.413 13.4076 13.7491 12.5625 13.75Z" fill="currentColor"/>
</svg>
);
const CameraOffSvg = () => (
<svg width="1em" height="1em" viewBox="0 0 25 25" fill="none">
<path d="M22.75 19.0183C22.54 19.0181 22.3324 18.9739 22.1406 18.8884C22.096 18.8686 22.0534 18.8444 22.0136 18.8162L18.1366 16.0872C17.9399 15.9488 17.7795 15.7651 17.6687 15.5517C17.5579 15.3383 17.5001 15.1014 17.5 14.8609V11.1391C17.5001 10.8986 17.5579 10.6617 17.6687 10.4483C17.7795 10.2349 17.9399 10.0512 18.1366 9.91281L22.0136 7.18375C22.0534 7.15555 22.096 7.13137 22.1406 7.11156C22.369 7.01003 22.6191 6.96717 22.8683 6.98688C23.1174 7.00658 23.3577 7.08824 23.5673 7.22441C23.7769 7.36059 23.9491 7.54697 24.0683 7.76663C24.1875 7.98629 24.25 8.23226 24.25 8.48219V17.5178C24.25 17.9156 24.092 18.2972 23.8107 18.5785C23.5294 18.8598 23.1478 19.0178 22.75 19.0178V19.0183ZM13.5625 19.75H4.9375C4.09239 19.7491 3.28214 19.413 2.68456 18.8154C2.08697 18.2179 1.75087 17.4076 1.75 16.5625V9.4375C1.75087 8.59239 2.08697 7.78214 2.68456 7.18456C3.28214 6.58697 4.09239 6.25087 4.9375 6.25H13.585C14.4241 6.25099 15.2286 6.58477 15.8219 7.1781C16.4152 7.77144 16.749 8.57589 16.75 9.415V16.5625C16.7491 17.4076 16.413 18.2179 15.8154 18.8154C15.2179 19.413 14.4076 19.7491 13.5625 19.75Z" fill="currentColor"/>
<rect x="23.3135" y="0.476074" width="2" height="32" rx="1" transform="rotate(45 23.3135 0.476074)" fill="currentColor"/>
</svg>
);
const PhoneSvg = () => (
<svg width="1em" height="1em" viewBox="0 0 26 11" fill="none">
<path d="M1.10074 4.05002C1.74774 3.40302 2.89061 2.72817 5.01193 2.12757C7.59496 1.40069 9.73186 0.97742 13.0196 0.973112C16.1926 0.972117 17.9918 1.22668 21.0395 2.11266C24.4813 3.11233 25.4373 4.48456 25.7021 5.21476C26.0196 6.08649 25.9663 6.85116 25.7906 7.73283C25.687 8.22986 25.5379 8.71628 25.3451 9.18594C25.3262 9.23334 25.309 9.27775 25.2937 9.31752C25.2036 9.55551 25.0667 9.9158 24.6325 10.1114C24.3435 10.2426 23.9944 10.2688 23.4107 10.2725C22.2145 10.2807 20.0687 9.7809 19.1154 9.43486C18.4737 9.20085 18.0498 9.04673 17.7253 8.72289C17.3457 8.34337 17.244 7.85978 17.1598 7.31221C17.1439 7.20946 17.1303 7.10969 17.117 7.01291C17.0378 6.42921 16.9974 6.27807 16.7899 6.1435C16.3723 5.87369 14.7978 5.4017 12.9881 5.40369C11.1783 5.40568 9.66822 5.88297 9.24959 6.15444C9.03315 6.29464 8.99305 6.4501 8.91217 7.05335C8.90057 7.13986 8.88897 7.22902 8.87505 7.3205C8.78456 7.93668 8.69142 8.40171 8.32085 8.77228L8.31886 8.77427C7.99636 9.09677 7.58038 9.233 6.89228 9.45972C5.99469 9.75538 3.81968 10.3056 2.61914 10.3003C2.03545 10.298 1.68643 10.2731 1.39739 10.1425C0.962193 9.94597 0.826959 9.586 0.734482 9.34702C0.719235 9.30725 0.702662 9.2635 0.683438 9.21577C0.492311 8.7456 0.344854 8.25885 0.242933 7.76167C0.0682546 6.88231 0.0172117 6.11731 0.335741 5.24393C0.497017 4.79315 0.7586 4.38491 1.10074 4.05002Z" fill="currentColor"/>
</svg>
);
export const MicOnIcon = (props: Partial<CustomIconComponentProps>) => (
<Icon component={MicOnSvg} {...props} />
);
export const MicOffIcon = (props: Partial<CustomIconComponentProps>) => (
<Icon component={MicOffSvg} {...props} />
);
export const CameraOnIcon = (props: Partial<CustomIconComponentProps>) => (
<Icon component={CameraOnSvg} {...props} />
);
export const CameraOffIcon = (props: Partial<CustomIconComponentProps>) => (
<Icon component={CameraOffSvg} {...props} />
);
export const PhoneIcon = (props: Partial<CustomIconComponentProps>) => (
<Icon component={PhoneSvg} {...props} />
);

View File

@ -0,0 +1,24 @@
'use client'
import AgoraRTC, { AgoraRTCProvider } from 'agora-rtc-react';
import { Session } from '../../../types/sessions';
import { Agora } from './Agora';
AgoraRTC.setLogLevel(0);
export const AgoraClient = ({ session, stopCalling, isCoach }: { session?: Session, stopCalling: () => void, isCoach: boolean }) => {
const remoteUser = isCoach ? (session?.clients?.length ? session?.clients[0] : undefined) : session?.coach;
return session ? (
<AgoraRTCProvider client={AgoraRTC.createClient({ mode: "rtc", codec: "vp8" })}>
{session && (
<Agora
sessionId={session.id}
secret={session.secret}
stopCalling={stopCalling}
remoteUser={remoteUser}
/>
)}
</AgoraRTCProvider>
) : null;
};

View File

@ -0,0 +1,49 @@
import { Button } from 'antd';
import { MicOffIcon, MicOnIcon, CameraOnIcon, CameraOffIcon, PhoneIcon } from '../icons';
interface MediaControlProps {
calling: boolean;
micOn: boolean;
cameraOn: boolean;
setMic: () => void;
setCamera: () => void;
setCalling: () => void;
}
export const MediaControl = ({
calling,
micOn,
cameraOn,
setMic,
setCamera,
setCalling,
}: MediaControlProps) => (
<div className="b-agora__controls">
<Button
className="b-agora__control"
type="primary"
shape="circle"
icon={micOn ? <MicOnIcon style={{ fontSize: 24, color: '#66A5AD' }} /> : <MicOffIcon style={{ fontSize: 24, color: '#fff' }} />}
danger={!micOn}
size="medium"
onClick={setMic}
/>
<Button
className="b-agora__control_big"
type="primary"
shape="circle"
icon={<PhoneIcon style={{ fontSize: 36, color: calling ? '#fff' : '#66A5AD' }} />}
danger={calling}
onClick={setCalling}
/>
<Button
className="b-agora__control"
type="primary"
shape="circle"
icon={cameraOn ? <CameraOnIcon style={{ fontSize: 24, color: '#66A5AD' }} /> : <CameraOffIcon style={{ fontSize: 24, color: '#fff' }} />}
danger={!cameraOn}
size="medium"
onClick={setCamera}
/>
</div>
);

View File

@ -0,0 +1 @@
export * from "./MediaControl";

View File

@ -1,286 +1,77 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react';
import {Tag, Button, notification, Empty} from 'antd';
import { RightOutlined, PlusOutlined, LeftOutlined } from '@ant-design/icons';
import Image from 'next/image';
import dayjs from 'dayjs';
import { Link } from '../../../navigation';
import { i18nText } from '../../../i18nKeys';
import { getDuration, getPrice } from '../../../utils/expert';
import { PublicUser, Session } from '../../../types/sessions';
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common';
import { approveRequestedSession, getSessionDetails } from '../../../actions/sessions';
import React, { useState, useEffect } from 'react';
import { SessionType } from '../../../types/sessions';
import { AUTH_USER } from '../../../constants/common';
import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { Loader } from '../../view/Loader';
import { DeclineSessionModal } from '../../Modals/DeclineSessionModal';
import { AddCommentModal } from '../../Modals/AddCommentModal';
import { useSessionDetails } from "../../../actions/hooks/useSessionDetails";
import { AgoraClient } from '../agora';
import { AccountMenu } from '../AccountMenu';
import { SessionDetailsContent } from './SessionDetailsContent';
import { useSessionTracking } from '../../../actions/hooks/useSessionTracking';
type SessionDetailsProps = {
locale: string;
sessionId: number;
goBack: () => void;
activeTab: number;
activeType: SessionType;
};
export const SessionDetails = ({ sessionId, locale, goBack, activeTab }: SessionDetailsProps) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [userId] = useLocalStorage(AUTH_USER, '');
const [loading, setLoading] = useState<boolean>(false);
const [approveLoading, setApproveLoading] = useState<boolean>(false);
const [errorData, setErrorData] = useState<any>();
const [session, setSession] = useState<Session>();
const [openDeclineModal, setOpenDeclineModal] = useState<boolean>(false);
const [openAddCommentModal, setOpenAddCommentModal] = useState<boolean>(false);
const fetchData = useCallback(() => {
setLoading(true);
setErrorData(undefined);
setSession(undefined);
getSessionDetails(locale, jwt, sessionId)
.then(({ data }) => {
setSession(data);
})
.catch((err) => {
setErrorData(err);
})
.finally(() => {
setLoading(false);
})
}, []);
useEffect(() => {
fetchData();
}, [sessionId]);
const onApproveSession = (tab: typeof activeTab) => {
if (tab === 1) {
setApproveLoading(true);
approveRequestedSession(locale, jwt, sessionId)
.then(() => {
goBack();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
})
.finally(() => {
setLoading(false);
});
}
};
const startDate = session?.scheduledStartAtUtc ? dayjs(session?.scheduledStartAtUtc).locale(locale) : null;
const endDate = session?.scheduledEndAtUtc ? dayjs(session?.scheduledEndAtUtc).locale(locale) : null;
const today = startDate ? dayjs().format('YYYY-MM-DD') === startDate.format('YYYY-MM-DD') : false;
const CoachCard = (coach?: PublicUser) => coach ? (
<div className="card-detail__expert">
<div className="card-detail__portrait">
<Image src={coach?.faceImageUrl || '/images/person.png'} width={140} height={140} alt="" />
</div>
<div className="card-detail__inner">
<Link href={`/experts/${coach?.id}` as any} target="_blank">
<div className="card-detail__name">{`${coach?.name} ${coach?.surname || ''}`}</div>
</Link>
{/* <div className="card-detail__info">
<div className="card-profile__subtitle">{coach?.specialityDesc}</div>
<div className="card-detail__lang">
{coach?.coachLanguages?.map((lang) => (
<Tag key={lang} className="skills__list__item">{lang}</Tag>
))}
</div>
</div> */}
<div className="card-detail__cost">
{getPrice(session?.cost)} <span>/ {getDuration(locale, session?.totalDuration)}</span>
</div>
<div className={`card-detail__date${today ? ' chosen': ''}${activeTab === 2 ? ' history' : ''}`}>
{today
? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`
: `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`}
</div>
<div className="card-detail__skills">
<div className="skills__list">
{session?.themesTags?.slice(0, 2).map((skill) => <Tag key={skill?.id} className="skills__list__item">{skill?.name}</Tag>)}
{session?.themesTags?.length > 2
? (
<Tag className="skills__list__more">
<Link href={`/experts/${coach?.id}` as any} target="_blank">
{`+${session?.themesTags?.length - 2}`}
</Link>
</Tag>
) : null }
</div>
</div>
{/* <div className="card-profile__desc">{coach?.description}</div> */}
<Link href={`/experts/${coach?.id}` as any} target="_blank" className="card-detail__more">
{i18nText('details', locale)}
<RightOutlined style={{ fontSize: '10px', padding: '0 7px' }}/>
</Link>
</div>
</div>
) : null;
const StudentCard = (student?: PublicUser | null) => student ? (
<div className="card-detail__expert">
<div className="card-detail__portrait">
<Image src={student?.faceImageUrl || '/images/person.png'} width={140} height={140} alt="" />
</div>
<div className="card-detail__inner">
<div className="card-detail__name">{`${student?.name} ${student?.surname || ''}`}</div>
<div className={`card-detail__date${today ? ' chosen': ''}${activeTab === 2 ? ' history' : ''}`}>
{today
? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`
: `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`}
</div>
<div className="card-detail__skills">
<div className="skills__list">
<Tag className="skills__list__item">{session?.themesTagName}</Tag>
</div>
</div>
{/* <div className="card-profile__desc">{student?.description}</div> */}
</div>
</div>
) : null;
export const SessionDetails = ({ sessionId, locale, activeType }: SessionDetailsProps) => {
const { session, errorData, loading, fetchData } = useSessionDetails(locale, sessionId);
const tracking = useSessionTracking(locale, sessionId);
const [isCalling, setIsCalling] = useState<boolean>(false);
const [userData] = useLocalStorage(AUTH_USER, '');
const { id: userId = 0 } = userData ? JSON.parse(userData) : {};
const client = session?.clients?.length ? session?.clients[0] : null;
const isCoach = +userId !== client?.id;
const Current = isCoach ? StudentCard(client) : CoachCard(session?.coach);
return (
<Loader
isLoading={loading}
errorData={errorData}
refresh={fetchData}
>
<div className="card-detail">
<div>
<Button
className="card-detail__back"
type="link"
icon={<LeftOutlined />}
onClick={goBack}
>
Back
</Button>
useEffect(() => {
if (isCalling) {
tracking.start();
} else {
tracking.stop();
}
}, [isCalling]);
const stopCalling = () => {
setIsCalling(false);
fetchData()
}
return isCalling
? (
<AgoraClient
session={session}
stopCalling={stopCalling}
isCoach={isCoach}
/>
) : (
<>
<div className="col-xl-3 col-lg-4 d-none d-lg-block">
<AccountMenu locale={locale} />
</div>
{Current}
{(activeTab === 0 || activeTab === 1) && (
<div className="card-detail__actions">
<Button
className="card-detail__apply"
onClick={() => onApproveSession(activeTab)}
loading={approveLoading}
<div className="col-xl-9 col-lg-8 ">
<div className="page-account__inner">
<Loader
isLoading={loading}
errorData={errorData}
refresh={fetchData}
>
{activeTab === 0 ? 'Start Session' : 'Confirm Session'}
</Button>
<Button
className="card-detail__decline"
onClick={() => setOpenDeclineModal(true)}
disabled={approveLoading}
>
Decline Session
</Button>
{session?.id && (
<DeclineSessionModal
open={openDeclineModal}
handleCancel={() => setOpenDeclineModal(false)}
activeTab={activeTab}
locale={locale}
sessionId={session.id}
success={goBack}
/>
)}
</div>
)}
{activeTab !== 1 && (
<>
{activeTab === 2 && (
<>
<div className="card-detail__name">Course Info</div>
<div className="card-detail__inner">
{/* <div className="card-detail__info">
<div className="card-profile__subtitle">{current?.specialityDesc}</div>
<div className="card-detail__lang">
{current?.coachLanguages?.map((lang) => (
<Tag key={lang} className="skills__list__item">{lang}</Tag>
))}
</div>
</div> */}
<div className="card-detail__cost">
{getPrice(session?.cost)} <span>/ {getDuration(locale, session?.totalDuration)}</span>
</div>
<div className="card-detail__skills">
<div className="skills__list">
{session?.themesTags?.slice(0, 2).map((skill) => <Tag key={skill?.id} className="skills__list__item">{skill?.name}</Tag>)}
{session?.themesTags?.length > 2
? (
<Tag className="skills__list__more">
{`+${session?.themesTags?.length - 2}`}
</Tag>
) : null }
</div>
</div>
{/* <div className="card-profile__desc">{current?.description}</div> */}
</div>
</>
)}
<div className="card-detail__comments">
<div className="card-detail__comments_header">
<div className="card-detail__comments_title">
{session?.clientComments?.length === 0 && session?.coachComments?.length === 0 ? 'Comments' : 'My Comments'}
</div>
{activeTab === 0 && (
<>
<Button
className="card-detail__comments_add"
type="link"
iconPosition="end"
icon={<PlusOutlined style={{ fontSize: 18 }} />}
onClick={() => setOpenAddCommentModal(true)}
>
Add new
</Button>
<AddCommentModal
open={openAddCommentModal}
handleCancel={() => setOpenAddCommentModal(false)}
locale={locale}
sessionId={sessionId}
refresh={fetchData}
/>
</>
)}
</div>
{(session?.clientComments?.length > 0 || session?.coachComments?.length > 0) ? (
<>
{(isCoach ? session?.coachComments : session?.clientComments)?.map(({ id, comment }) => (
<div key={`my_${id}`} className="card-detail__comments_item">
{comment}
</div>
))}
{(isCoach ? session?.clientComments : session?.coachComments)?.length > 0 && (
<div className="card-detail__comments_title">
{isCoach ? 'Client Comments' : 'Coach Comments'}
</div>
)}
{(isCoach ? session?.clientComments : session?.coachComments)?.map(({ id , comment }) => (
<div key={`oth_${id}`} className="card-detail__comments_item">
{comment}
</div>
))}
</>
) : (
<>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</>
{session && (
<SessionDetailsContent
locale={locale}
session={session}
activeType={activeType}
startSession={() => setIsCalling(true)}
refresh={fetchData}
isCoach={isCoach}
/>
)}
</div>
</>
)}
</div>
</Loader>
);
</Loader>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,299 @@
'use client'
import React, {useState} from 'react';
import {Button, Empty, notification, Tag} from 'antd';
import {LeftOutlined, PlusOutlined, RightOutlined} from '@ant-design/icons';
import Image from 'next/image';
import dayjs from 'dayjs';
import {Link, useRouter} from '../../../navigation';
import {i18nText} from '../../../i18nKeys';
import {getDuration, getPrice} from '../../../utils/expert';
import {PublicUser, Session, SessionState, SessionType} from '../../../types/sessions';
import {AUTH_TOKEN_KEY} from '../../../constants/common';
import {approveRequestedSession, finishSession} from '../../../actions/sessions';
import {useLocalStorage} from '../../../hooks/useLocalStorage';
import {DeclineSessionModal} from '../../Modals/DeclineSessionModal';
import {AddCommentModal} from '../../Modals/AddCommentModal';
type SessionDetailsContentProps = {
locale: string;
session: Session;
activeType: SessionType;
startSession: () => void;
refresh: () => void;
isCoach: boolean;
};
export const SessionDetailsContent = ({ session, locale, activeType, startSession, refresh, isCoach }: SessionDetailsContentProps) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [approveLoading, setApproveLoading] = useState<boolean>(false);
const [finishLoading, setFinishLoading] = useState<boolean>(false);
const [openDeclineModal, setOpenDeclineModal] = useState<boolean>(false);
const [openAddCommentModal, setOpenAddCommentModal] = useState<boolean>(false);
const router = useRouter();
const goBack = () => router.push(`/account/sessions/${activeType}`);
const onApproveSession = () => {
if (activeType === SessionType.REQUESTED) {
setApproveLoading(true);
approveRequestedSession(locale, jwt, session.id)
.then(() => {
goBack();
})
.catch((err) => {
notification.error({
message: 'Error approve session',
description: err?.response?.data?.errMessage
});
})
.finally(() => {
setApproveLoading(false);
});
} else {
startSession();
}
};
const onFinishSession = () => {
if (isCoach) {
setFinishLoading(true);
finishSession(locale, jwt, session.id)
.then(() => {
goBack();
})
.catch((err) => {
notification.error({
message: 'Error finish session',
description: err?.response?.data?.errMessage
});
})
.finally(() => {
setFinishLoading(false);
})
}
};
const startDate = session?.scheduledStartAtUtc ? dayjs(session?.scheduledStartAtUtc).locale(locale) : null;
const endDate = session?.scheduledEndAtUtc ? dayjs(session?.scheduledEndAtUtc).locale(locale) : null;
const today = startDate ? dayjs().format('YYYY-MM-DD') === startDate.format('YYYY-MM-DD') : false;
const CoachCard = (coach?: PublicUser) => coach ? (
<div className="card-detail__expert">
<div className="card-detail__portrait">
<Image src={coach?.faceImageUrl || '/images/person.png'} width={140} height={140} alt="" />
</div>
<div className="card-detail__inner">
<Link href={`/experts/${coach?.id}` as any} target="_blank">
<div className="card-detail__name">{`${coach?.name} ${coach?.surname || ''}`}</div>
</Link>
{/* <div className="card-detail__info">
<div className="card-profile__subtitle">{coach?.specialityDesc}</div>
<div className="card-detail__lang">
{coach?.coachLanguages?.map((lang) => (
<Tag key={lang} className="skills__list__item">{lang}</Tag>
))}
</div>
</div> */}
<div className="card-detail__cost">
{getPrice(session?.cost)} <span>/ {getDuration(locale, session?.totalDuration)}</span>
</div>
<div className={`card-detail__date${today ? ' chosen': ''}${activeType === SessionType.RECENT ? ' history' : ''}`}>
{today
? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`
: `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`}
</div>
<div className="card-detail__skills">
<div className="skills__list">
{session?.themesTags?.slice(0, 2).map((skill) => <Tag key={skill?.id} className="skills__list__item">{skill?.name}</Tag>)}
{session?.themesTags?.length > 2
? (
<Tag className="skills__list__more">
<Link href={`/experts/${coach?.id}` as any} target="_blank">
{`+${session?.themesTags?.length - 2}`}
</Link>
</Tag>
) : null }
</div>
</div>
{/* <div className="card-profile__desc">{coach?.description}</div> */}
<Link href={`/experts/${coach?.id}` as any} target="_blank" className="card-detail__more">
{i18nText('details', locale)}
<RightOutlined style={{ fontSize: '10px', padding: '0 7px' }}/>
</Link>
</div>
</div>
) : null;
const StudentCard = (student?: PublicUser | null) => student ? (
<div className="card-detail__expert">
<div className="card-detail__portrait">
<Image src={student?.faceImageUrl || '/images/person.png'} width={140} height={140} alt="" />
</div>
<div className="card-detail__inner">
<div className="card-detail__name">{`${student?.name} ${student?.surname || ''}`}</div>
<div className={`card-detail__date${today ? ' chosen': ''}${activeType === SessionType.RECENT ? ' history' : ''}`}>
{today
? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`
: `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`}
</div>
<div className="card-detail__skills">
<div className="skills__list">
<Tag className="skills__list__item">{session?.themesTagName}</Tag>
</div>
</div>
{/* <div className="card-profile__desc">{student?.description}</div> */}
{activeType === SessionType.REQUESTED && session?.clientComment && (
<div className="card-detail__comments">
<div className="card-detail__comments_item">
{session.clientComment}
</div>
</div>
)}
</div>
</div>
) : null;
const client = session?.clients?.length ? session?.clients[0] : null;
const Current = isCoach ? StudentCard(client) : CoachCard(session?.coach);
return (
<div className="card-detail">
<div>
<Button
className="card-detail__back"
type="link"
icon={<LeftOutlined />}
onClick={goBack}
>
Back
</Button>
</div>
{Current}
{(activeType === SessionType.UPCOMING || activeType === SessionType.REQUESTED) &&
(session?.state === SessionState.CREATED || session?.state === SessionState.PAID
|| session?.state === SessionState.COACH_APPROVED || session?.state === SessionState.STARTED) && (
<div className="card-detail__actions">
<Button
className="card-detail__apply"
onClick={onApproveSession}
loading={approveLoading}
disable={finishLoading}
>
{activeType === SessionType.UPCOMING
? (session?.state === SessionState.STARTED ? 'Join Session' : 'Start Session')
: 'Confirm Session'}
</Button>
{session?.state === SessionState.STARTED && isCoach && (
<Button
className="card-detail__decline"
onClick={onFinishSession}
loading={finishLoading}
>
Finish Session
</Button>
)}
{session?.id && session?.state !== SessionState.STARTED && (
<>
<Button
className="card-detail__decline"
onClick={() => setOpenDeclineModal(true)}
disabled={approveLoading}
>
Decline Session
</Button>
<DeclineSessionModal
open={openDeclineModal}
handleCancel={() => setOpenDeclineModal(false)}
activeType={activeType}
locale={locale}
sessionId={session.id}
success={goBack}
/>
</>
)}
</div>
)}
{activeType !== SessionType.REQUESTED && (
<>
{activeType === SessionType.RECENT && (
<>
<div className="card-detail__name">Course Info</div>
<div className="card-detail__inner">
{/* <div className="card-detail__info">
<div className="card-profile__subtitle">{current?.specialityDesc}</div>
<div className="card-detail__lang">
{current?.coachLanguages?.map((lang) => (
<Tag key={lang} className="skills__list__item">{lang}</Tag>
))}
</div>
</div> */}
<div className="card-detail__cost">
{getPrice(session?.cost)} <span>/ {getDuration(locale, session?.totalDuration)}</span>
</div>
<div className="card-detail__skills">
<div className="skills__list">
{session?.themesTags?.slice(0, 2).map((skill) => <Tag key={skill?.id} className="skills__list__item">{skill?.name}</Tag>)}
{session?.themesTags?.length > 2
? (
<Tag className="skills__list__more">
{`+${session?.themesTags?.length - 2}`}
</Tag>
) : null }
</div>
</div>
{/* <div className="card-profile__desc">{current?.description}</div> */}
</div>
</>
)}
<div className="card-detail__comments">
<div className="card-detail__comments_header">
<div className="card-detail__comments_title">
{session?.clientComments?.length === 0 && session?.coachComments?.length === 0 ? 'Comments' : 'My Comments'}
</div>
{activeType === SessionType.UPCOMING && (
<>
<Button
className="card-detail__comments_add"
type="link"
iconPosition="end"
icon={<PlusOutlined style={{ fontSize: 18 }} />}
onClick={() => setOpenAddCommentModal(true)}
>
Add new
</Button>
<AddCommentModal
open={openAddCommentModal}
handleCancel={() => setOpenAddCommentModal(false)}
locale={locale}
sessionId={session.id}
refresh={refresh}
/>
</>
)}
</div>
{(session?.clientComments?.length > 0 || session?.coachComments?.length > 0) ? (
<>
{(isCoach ? session?.coachComments : session?.clientComments)?.map(({ id, comment }) => (
<div key={`my_${id}`} className="card-detail__comments_item">
{comment}
</div>
))}
{(isCoach ? session?.clientComments : session?.coachComments)?.length > 0 && (
<div className="card-detail__comments_title">
{isCoach ? 'Client Comments' : 'Coach Comments'}
</div>
)}
{(isCoach ? session?.clientComments : session?.coachComments)?.map(({ id , comment }) => (
<div key={`oth_${id}`} className="card-detail__comments_item">
{comment}
</div>
))}
</>
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</div>
</>
)}
</div>
);
};

View File

@ -14,22 +14,23 @@ import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common';
import { getRecentSessions, getRequestedSessions, getUpcomingSessions } from '../../../actions/sessions';
import { Session, Sessions, SessionType } from '../../../types/sessions';
import { useRouter } from '../../../navigation';
import { i18nText } from '../../../i18nKeys';
type SessionsTabsProps = {
locale: string;
updateSession: (val: number) => void;
activeTab: number;
updateTab: (tab: number) => void;
activeTab: SessionType;
};
export const SessionsTabs = ({ locale, updateSession, activeTab, updateTab }: SessionsTabsProps) => {
export const SessionsTabs = ({ locale, activeTab }: SessionsTabsProps) => {
const [sort, setSort] = useState<string>();
const [sessions, setSessions] = useState<Sessions>();
const [loading, setLoading] = useState<boolean>(true);
const [errorData, setErrorData] = useState<any>();
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [userId] = useLocalStorage(AUTH_USER, '');
const [userData] = useLocalStorage(AUTH_USER, '');
const { id: userId = 0 } = userData ? JSON.parse(userData) : {};
const router = useRouter();
const fetchData = () => {
setErrorData(undefined);
@ -65,7 +66,7 @@ export const SessionsTabs = ({ locale, updateSession, activeTab, updateTab }: Se
const onClickSession = (event: MouseEvent<HTMLDivElement>, id: number) => {
event.stopPropagation();
event.preventDefault();
updateSession(id);
router.push(`${id}`);
};
const getChildren = (list?: Session[]) => (
@ -155,17 +156,17 @@ export const SessionsTabs = ({ locale, updateSession, activeTab, updateTab }: Se
refresh={fetchData}
>
<div className="tabs-session">
{tabs.map((tab, index) => (
{tabs.map(({ key, label }) => (
<Space
key={index}
className={`tabs-session__item ${index === activeTab ? 'active' : ''}`}
onClick={() => updateTab(index)}
key={key}
className={`tabs-session__item ${key === activeTab ? 'active' : ''}`}
onClick={() => router.push(`/account/sessions/${key}`)}
>
{tab.label}
{label}
</Space>
))}
</div>
{tabs[activeTab].children}
{tabs.filter(({ key }) => key === activeTab)[0].children}
</Loader>
);
};

View File

@ -1,26 +1,5 @@
'use client'
import React, { useState } from 'react';
import { SessionDetails } from './SessionDetails';
import { SessionsTabs } from './SessionsTabs';
export const SessionsAll = ({ locale }: { locale: string }) => {
const [customSession, setCustomSession] = useState<number | undefined>();
const [activeTab, setActiveTab] = useState<number>(0);
return customSession ? (
<SessionDetails
locale={locale}
sessionId={customSession}
goBack={() => setCustomSession(undefined)}
activeTab={activeTab}
/>
) : (
<SessionsTabs
locale={locale}
updateSession={setCustomSession}
activeTab={activeTab}
updateTab={setActiveTab}
/>
);
};
export * from './SessionDetails';
export * from './SessionsTabs';
export * from './SessionDetailsContent';

View File

@ -91,7 +91,7 @@ export const AddCommentModal: FC<AddCommentModalProps> = ({
className="b-textarea"
rows={4}
maxLength={1000}
placeholder="Describe the reason for the rejection"
placeholder="Your comment"
/>
</Form.Item>
</Form>

View File

@ -1,18 +1,19 @@
'use client';
import React, { FC, useEffect, useState } from 'react';
import { Modal, Form, Input, notification } from 'antd';
import { Form, Input, Modal, notification } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { FilledButton } from '../view/FilledButton';
import { i18nText } from '../../i18nKeys';
import { cancelUpcomingSession, declineRequestedSession } from '../../actions/sessions';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { SessionType } from '../../types/sessions';
import { AUTH_TOKEN_KEY } from '../../constants/common';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { cancelUpcomingSession, declineRequestedSession } from '../../actions/sessions';
// import { i18nText } from '../../i18nKeys';
import { FilledButton } from '../view/FilledButton';
type DeclineModalProps = {
open: boolean;
handleCancel: () => void;
activeTab: 0 | 1;
activeType: SessionType;
locale: string;
sessionId: number;
success: () => void;
@ -21,7 +22,7 @@ type DeclineModalProps = {
export const DeclineSessionModal: FC<DeclineModalProps> = ({
open,
handleCancel,
activeTab,
activeType,
locale,
sessionId,
success
@ -38,11 +39,11 @@ export const DeclineSessionModal: FC<DeclineModalProps> = ({
if (form) {
form.resetFields();
}
}, [activeTab]);
}, [activeType]);
const onDecline = () => {
form.validateFields().then(({ reason }) => {
const fetchFunc = activeTab === 0 ? cancelUpcomingSession : declineRequestedSession;
const fetchFunc = activeType === SessionType.UPCOMING ? cancelUpcomingSession : declineRequestedSession;
setLoading(true);
fetchFunc(locale, jwt, { sessionId, reason })

View File

@ -41,7 +41,7 @@ export const EnterContent: FC<EnterProps> = ({
if (data.jwtToken) {
getPersonalData(locale, data.jwtToken)
.then(({ data: profile }) => {
localStorage.setItem(AUTH_USER, profile.id.toString());
localStorage.setItem(AUTH_USER, JSON.stringify(profile));
updateToken(data.jwtToken);
handleCancel();
})

View File

@ -41,7 +41,7 @@ export const RegisterContent: FC<RegisterProps> = ({
setPersonData( { login, password, role: 'client', languagesLinks: [] }, locale, data.jwtToken)
.then(({ data: profile }) => {
updateToken(data.jwtToken);
localStorage.setItem(AUTH_USER, profile.userData.id.toString());
localStorage.setItem(AUTH_USER, JSON.stringify(profile.userData));
handleCancel();
})
.catch((error) => {

View File

@ -38,7 +38,7 @@ export const HeaderAuthLinks: FC<HeaderAuthLinksProps> = ({
return token
? (
<li>
<Link href={'/account/sessions' as any} className={pathname === 'account' ? 'active' : ''}>
<Link href={'/account/sessions/upcoming' as any} className={pathname === 'account' ? 'active' : ''}>
{i18nText('account', locale)}
</Link>
</li>

View File

@ -27,7 +27,7 @@ export const Header: FC<HeaderProps> = ({ locale }) => {
alt=""
/>
</Link>
<Suspense>
<Suspense fallback={null}>
<HeaderMenu
locale={locale}
linkConfig={routes}
@ -36,7 +36,7 @@ export const Header: FC<HeaderProps> = ({ locale }) => {
</Suspense>
</div>
</header>
<Suspense>
<Suspense fallback={null}>
<HeaderMobileMenu
locale={locale}
linkConfig={routes}

View File

@ -0,0 +1,138 @@
.b-agora {
&__wrap {
width: 100%;
height: 716px;
border-radius: 16px;
position: relative;
overflow: hidden;
}
&__container {
display: flex;
flex-direction: column;
background:rgba(0,0,0,.3);
height: 100%;
}
&__panel {
position: absolute;
display: flex;
bottom: 0;
left: 0;
right: 0;
padding: 0 24px 24px;
gap: 24px;
justify-content: space-between;
align-items: flex-end;
z-index: 2;
}
&__controls {
display: inline-flex;
gap: 30px;
align-items: center;
}
&__control {
width: 40px !important;
height: 40px !important;
background: #fff !important;
box-shadow: none !important;
&.ant-btn-dangerous {
background: #D93E5C !important;
}
}
&__control_big {
width: 60px !important;
height: 60px !important;
background: #fff !important;
box-shadow: none !important;
&.ant-btn-dangerous {
background: #D93E5C !important;
}
}
&__local {
&_user {
width: 344px;
height: 230px;
border-radius: 16px;
overflow: hidden;
position: relative;
}
&_base {
top: 0;
bottom: 0;
left: 0;
right: 0;
//background: rgba(0, 59, 70, 0.6);
background: #66A5AD;
position: absolute;
display: flex;
& ~ div, & ~ div *{
background-color: transparent !important;
}
}
}
&__call {
&_avatar {
background-color: #fff;
position: relative;
width: 80px;
height: 80px;
border-radius: 16px;
border: 2px solid #FFF;
background-position: 50%;
background-repeat: no-repeat;
background-size: cover;
margin: auto;
display: flex;
justify-content: center;
align-items: center;
}
}
&__remote {
&_user {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
&_warning {
position: absolute;
top: 20px;
left: 0;
right: 0;
color: #fff;
text-align: center;
}
&_base {
top: 0;
bottom: 0;
left: 0;
right: 0;
background: #003B46;
position: absolute;
display: flex;
}
}
&__video {
&_remote {
video {
object-fit: contain !important;
}
}
}
}

View File

@ -1,2 +1,3 @@
@import "_details.scss";
@import "_decline-modal.scss";
@import "_agora.scss";

View File

@ -35,4 +35,15 @@
line-height: 15px !important;
}
}
&__logout {
width: 100%;
height: 49px !important;
color: #D93E5C !important;
font-style: normal;
font-weight: 600 !important;
padding: 0 !important;
font-size: 1.125rem !important;
text-align: left !important;
}
}

View File

@ -90,6 +90,17 @@ export type PublicUser = {
// ]
// };
export enum SessionState {
CREATED = 'created',
PAID = 'paid',
COACH_APPROVED = 'coachApproved',
STARTED = 'started',
FINISHED = 'finished',
COACH_DECLINED = 'coachDeclined',
CLIENT_CANCELED = 'clientCanceled',
COACH_CANCELED = 'coachCanceled'
}
type SessionTag = {
id: number;
groupId?: number;
@ -118,7 +129,7 @@ export type Session = {
id: number;
scheduledStartAtUtc?: string;
scheduledEndAtUtc?: string;
state?: string;
state?: SessionState;
clientComment?: string;
secret?: string;
cost?: number;

14
src/utils/account.ts Normal file
View File

@ -0,0 +1,14 @@
import { i18nText } from '../i18nKeys';
const ROUTES = ['sessions', 'notifications', 'support', 'information', 'settings', 'messages', 'work-with-us'];
const COUNTS: Record<string, number> = {
sessions: 12,
notifications: 5,
messages: 113
};
export const getMenuConfig = (locale: string) => ROUTES.map((path) => ({
path,
title: i18nText(`accountMenu.${path}`, locale),
count: COUNTS[path] || undefined
}));

108
src/utils/agora/helpers.ts Normal file
View File

@ -0,0 +1,108 @@
import type { MaybePromise } from 'agora-rtc-react';
export type Disposer = () => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Fn = (...args: any[]) => any;
export function invoke<T>(fn: () => T): T | void {
try {
return fn();
} catch (e) {
console.error(e);
}
}
export function joinDisposers(disposers: Disposer[]): Disposer {
return () => disposers.forEach(invoke);
}
export function interval(fn: Fn, interval: number): Disposer {
const id = setInterval(fn, interval);
return () => clearInterval(id);
}
export function timeout(fn: Fn, ms: number): Disposer {
const id = setTimeout(fn, ms);
return () => clearTimeout(id);
}
export interface AsyncTaskRunner {
run: (this: void, task: () => MaybePromise<void | (() => MaybePromise<void>)>) => void;
dispose: (this: void) => void;
}
/**
* Chain async tasks. During the task running/stopping, if multiple tasks are triggered, only the last one will be executed.
*/
export function createAsyncTaskRunner(): AsyncTaskRunner {
let isRunning: boolean | undefined;
let nextTask: undefined | (() => MaybePromise<void>);
let disposer: undefined | void | (() => MaybePromise<void>);
function runNextTask() {
if (nextTask) {
const _nextTask = nextTask;
nextTask = void 0;
_nextTask();
}
}
async function disposeEffect() {
if (disposer) {
const _disposer = disposer;
disposer = void 0;
try {
await _disposer();
} catch (e) {
console.error(e);
}
}
}
async function runTask(effect: () => MaybePromise<void | (() => MaybePromise<void>)>) {
isRunning = true;
await disposeEffect();
try {
disposer = await effect();
} catch (e) {
console.error(e);
}
isRunning = false;
runNextTask();
}
async function stopTask() {
isRunning = true;
await disposeEffect();
isRunning = false;
runNextTask();
}
function run(task: () => MaybePromise<void | (() => MaybePromise<void>)>): void {
if (isRunning) {
nextTask = () => runTask(task);
} else {
runTask(task);
}
}
function dispose(): void {
if (isRunning) {
nextTask = stopTask;
} else {
stopTask();
}
}
return {
run,
dispose,
};
}

163
src/utils/agora/tools.ts Normal file
View File

@ -0,0 +1,163 @@
import type { MutableRefObject, Ref, RefObject } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { MaybePromiseOrNull } from 'agora-rtc-react';
import { AsyncTaskRunner, createAsyncTaskRunner } from './helpers';
export const useIsomorphicLayoutEffect =
typeof document !== 'undefined' ? useLayoutEffect : useEffect;
export function isPromise<T>(value: MaybePromiseOrNull<T>): value is PromiseLike<T> {
return value != null && typeof (value as PromiseLike<T>).then === "function";
}
export function useForceUpdate() {
const [_, forceUpdate] = useState(0);
return useCallback(() => forceUpdate(n => (n + 1) | 0), []);
}
export function useIsUnmounted(): RefObject<boolean> {
const isUnmountRef = useRef(false);
useEffect(() => {
isUnmountRef.current = false;
return () => {
isUnmountRef.current = true;
};
}, []);
return isUnmountRef;
}
/**
* Leave promise unresolved when the component is unmounted.
*
* ```js
* const sp = useSafePromise()
* setLoading(true)
* try {
* const result1 = await sp(fetchData1())
* const result2 = await sp(fetchData2(result1))
* setData(result2)
* } catch(e) {
* setHasError(true)
* }
* setLoading(false)
* ```
*/
export function useSafePromise() {
const isUnmountRef = useIsUnmounted();
function safePromise<T, E = unknown>(
promise: PromiseLike<T>,
onUnmountedError?: (error: E) => void,
) {
// the async promise executor is intended
// eslint-disable-next-line no-async-promise-executor
return new Promise<T>(async (resolve, reject) => {
try {
const result = await promise;
if (!isUnmountRef.current) {
resolve(result);
}
// unresolved promises will be garbage collected.
} catch (error) {
if (!isUnmountRef.current) {
reject(error);
} else if (onUnmountedError) {
onUnmountedError(error as E);
} else {
if (process.env.NODE_ENV === 'development') {
console.error("An error occurs from a promise after a component is unmounted", error);
}
}
}
});
}
return useCallback(safePromise, [isUnmountRef]);
}
export function applyRef<T>(ref: Ref<T>, value: T) {
if (typeof ref === "function") {
ref(value);
} else if (typeof ref === "object" && ref) {
(ref as MutableRefObject<T>).current = value;
}
}
/**
* Sugar to merge forwarded ref and produce a local ref (state).
*
* ```jsx
* const Button = forwardRef((props, ref) => {
* const [div, setDiv] = useForwardRef(ref)
* // use 'div' here
* return <div ref={setDiv} />
* })
* ```
*/
export function useForwardRef<T>(ref: Ref<T>): [T | null, (value: T | null) => void] {
const [current, setCurrent] = useState<T | null>(null);
const forwardedRef = useCallback(
(value: T | null) => {
setCurrent(value);
applyRef(ref, value);
},
[ref, setCurrent],
);
return [current, forwardedRef];
}
/**
* Await a promise or return the value directly.
*/
export function useAwaited<T>(promise: MaybePromiseOrNull<T>): T | undefined {
const sp = useSafePromise();
const [value, setValue] = useState<T | undefined>();
useIsomorphicLayoutEffect(() => {
if (isPromise(promise)) {
sp(promise).then(setValue);
} else {
setValue(promise);
}
}, [promise, sp]);
return value;
}
/**
* Accepts a function that contains imperative, possibly asynchronous effect-ful code.
* During the side-effect running/removing, if multiple effects are triggered, only the last one will be executed.
*/
export function useAsyncEffect(
effect: () => MaybePromise<void | (() => MaybePromise<void>)>,
deps?: ReadonlyArray<unknown>,
): void {
const runnerRef = useRef<AsyncTaskRunner | undefined>();
useEffect(() => {
const { run, dispose } = (runnerRef.current ||= createAsyncTaskRunner());
run(effect);
return dispose;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
export function compareVersion(v1: string, v2: string): number {
const v1Parts = v1.split(".");
const v2Parts = v2.split(".");
const maxLength = Math.max(v1Parts.length, v2Parts.length);
for (let i = 0; i < maxLength; i++) {
const part1 = parseInt(v1Parts[i] || "0");
const part2 = parseInt(v2Parts[i] || "0");
if (part1 > part2) {
return 1;
}
if (part1 < part2) {
return -1;
}
}
return 0;
}