diff --git a/docker-compose.yml b/docker-compose.yml
index f852f00..48f2c80 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -284,11 +284,13 @@ services:
build:
context: ./excalidraw-server
dockerfile: Dockerfile
+ args:
+ - NEXT_PUBLIC_BASE_PATH=/devboard
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
restart: unless-stopped
environment:
- NODE_ENV=${NODE_ENV:-production}
- - NEXT_PUBLIC_BASE_PATH=
+ - NEXT_PUBLIC_BASE_PATH=/devboard
ports:
- "${EXCALIDRAW_PORT:-3001}:3001"
networks:
diff --git a/excalidraw-server/Dockerfile b/excalidraw-server/Dockerfile
index 6bf0de9..acacf92 100644
--- a/excalidraw-server/Dockerfile
+++ b/excalidraw-server/Dockerfile
@@ -16,7 +16,9 @@ RUN npx patch-package
# Копируем исходный код
COPY . .
-# Собираем приложение
+# Собираем приложение (NEXT_PUBLIC_* переменные нужны на этапе сборки)
+ARG NEXT_PUBLIC_BASE_PATH=
+ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
ENV NODE_ENV=production
RUN npm run build
diff --git a/front_minimal/package-lock.json b/front_minimal/package-lock.json
index 1516666..2fe3e5f 100644
--- a/front_minimal/package-lock.json
+++ b/front_minimal/package-lock.json
@@ -29,6 +29,9 @@
"@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1",
+ "@livekit/components-core": "^0.12.13",
+ "@livekit/components-react": "^2.9.20",
+ "@livekit/components-styles": "^1.2.0",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11",
@@ -51,6 +54,7 @@
"autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6",
"axios": "^1.7.2",
+ "date-fns": "^3.6.0",
"dayjs": "^1.11.11",
"embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5",
@@ -62,6 +66,7 @@
"i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1",
+ "livekit-client": "^2.17.2",
"lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2",
@@ -3239,6 +3244,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bufbuild/protobuf": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
+ "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
+ },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
@@ -4076,20 +4087,22 @@
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
},
"node_modules/@floating-ui/core": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz",
- "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==",
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
"dependencies": {
- "@floating-ui/utils": "^0.2.1"
+ "@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
- "version": "1.6.1",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz",
- "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==",
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+ "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+ "license": "MIT",
"dependencies": {
- "@floating-ui/core": "^1.6.0",
- "@floating-ui/utils": "^0.2.1"
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
@@ -4105,9 +4118,10 @@
}
},
"node_modules/@floating-ui/utils": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
- "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
},
"node_modules/@fontsource/barlow": {
"version": "5.0.13",
@@ -4382,6 +4396,76 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@livekit/components-core": {
+ "version": "0.12.13",
+ "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
+ "integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@floating-ui/dom": "1.7.4",
+ "loglevel": "1.9.1",
+ "rxjs": "7.8.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "livekit-client": "^2.17.2",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@livekit/components-react": {
+ "version": "2.9.20",
+ "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.20.tgz",
+ "integrity": "sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@livekit/components-core": "0.12.13",
+ "clsx": "2.1.1",
+ "events": "^3.3.0",
+ "jose": "^6.0.12",
+ "usehooks-ts": "3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
+ "livekit-client": "^2.17.2",
+ "react": ">=18",
+ "react-dom": ">=18",
+ "tslib": "^2.6.2"
+ },
+ "peerDependenciesMeta": {
+ "@livekit/krisp-noise-filter": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@livekit/components-styles": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz",
+ "integrity": "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@livekit/mutex": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
+ "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@livekit/protocol": {
+ "version": "1.44.0",
+ "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
+ "integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@bufbuild/protobuf": "^1.10.0"
+ }
+ },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
@@ -6740,6 +6824,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/dom-mediacapture-record": {
+ "version": "1.0.22",
+ "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
+ "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/@types/geojson": {
"version": "7946.0.13",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
@@ -7994,6 +8085,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/dayjs": {
"version": "1.11.11",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
@@ -10610,6 +10711,15 @@
"restructure": "^3.0.0"
}
},
+ "node_modules/jose": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz",
+ "integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -10779,6 +10889,40 @@
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
},
+ "node_modules/livekit-client": {
+ "version": "2.17.2",
+ "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz",
+ "integrity": "sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@livekit/mutex": "1.1.1",
+ "@livekit/protocol": "1.44.0",
+ "events": "^3.3.0",
+ "jose": "^6.1.0",
+ "loglevel": "^1.9.2",
+ "sdp-transform": "^2.15.0",
+ "ts-debounce": "^4.0.0",
+ "tslib": "2.8.1",
+ "typed-emitter": "^2.1.0",
+ "webrtc-adapter": "^9.0.1"
+ },
+ "peerDependencies": {
+ "@types/dom-mediacapture-record": "^1"
+ }
+ },
+ "node_modules/livekit-client/node_modules/loglevel": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
+ "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/loglevel"
+ }
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -10830,6 +10974,19 @@
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
},
+ "node_modules/loglevel": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
+ "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/loglevel"
+ }
+ },
"node_modules/long": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
@@ -13237,9 +13394,10 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/rxjs": {
- "version": "7.8.1",
- "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
- "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
@@ -13323,6 +13481,21 @@
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
},
+ "node_modules/sdp": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
+ "integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
+ "license": "MIT"
+ },
+ "node_modules/sdp-transform": {
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
+ "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
+ "license": "MIT",
+ "bin": {
+ "sdp-verify": "checker.js"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -14044,6 +14217,12 @@
"typescript": ">=4.2.0"
}
},
+ "node_modules/ts-debounce": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
+ "integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
+ "license": "MIT"
+ },
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -14069,9 +14248,10 @@
}
},
"node_modules/tslib": {
- "version": "2.6.2",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
- "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
},
"node_modules/turndown": {
"version": "7.2.0",
@@ -14185,6 +14365,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typed-emitter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
+ "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "rxjs": "*"
+ }
+ },
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
@@ -14473,6 +14662,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/usehooks-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
+ "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.debounce": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=16.15.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -14580,6 +14784,19 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
+ "node_modules/webrtc-adapter": {
+ "version": "9.0.4",
+ "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
+ "integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "sdp": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=6.0.0",
+ "npm": ">=3.10.0"
+ }
+ },
"node_modules/websocket-driver": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
diff --git a/front_minimal/package.json b/front_minimal/package.json
index 4028691..0a93e0a 100644
--- a/front_minimal/package.json
+++ b/front_minimal/package.json
@@ -40,6 +40,9 @@
"@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1",
+ "@livekit/components-core": "^0.12.13",
+ "@livekit/components-react": "^2.9.20",
+ "@livekit/components-styles": "^1.2.0",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11",
@@ -62,6 +65,7 @@
"autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6",
"axios": "^1.7.2",
+ "date-fns": "^3.6.0",
"dayjs": "^1.11.11",
"embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5",
@@ -73,6 +77,7 @@
"i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1",
+ "livekit-client": "^2.17.2",
"lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2",
diff --git a/front_minimal/src/actions/calendar.js b/front_minimal/src/actions/calendar.js
index 70cf622..790d282 100644
--- a/front_minimal/src/actions/calendar.js
+++ b/front_minimal/src/actions/calendar.js
@@ -1,44 +1,62 @@
import { useMemo } from 'react';
+import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
import useSWR, { mutate } from 'swr';
-import { getCalendarLessons, getMentorStudents, getMentorSubjects, createCalendarLesson } from 'src/utils/dashboard-api';
+
+import {
+ getCalendarLessons,
+ getMentorStudents,
+ getMentorSubjects,
+ createCalendarLesson,
+ updateCalendarLesson,
+ deleteCalendarLesson,
+} from 'src/utils/dashboard-api';
// ----------------------------------------------------------------------
-const CALENDAR_ENDPOINT = '/schedule/lessons/calendar/';
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
const SUBJECTS_ENDPOINT = '/schedule/subjects/';
const swrOptions = {
revalidateIfStale: true,
- revalidateOnFocus: true,
+ revalidateOnFocus: false,
revalidateOnReconnect: true,
};
+// Ключ кэша для календаря (по месяцу)
+function calendarKey(date = new Date()) {
+ const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
+ const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
+ return ['calendar', start, end];
+}
+
// ----------------------------------------------------------------------
-export function useGetEvents() {
- const startDate = '2026-02-01';
- const endDate = '2026-04-30';
+export function useGetEvents(currentDate) {
+ const date = currentDate || new Date();
+ const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
+ const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
const { data: response, isLoading, error, isValidating } = useSWR(
- [CALENDAR_ENDPOINT, startDate, endDate],
- ([url, start, end]) => getCalendarLessons(start, end),
+ ['calendar', start, end],
+ ([, s, e]) => getCalendarLessons(s, e),
swrOptions
);
const memoizedValue = useMemo(() => {
- const lessonsArray = response?.data?.lessons || [];
-
+ const lessonsArray = response?.data?.lessons || response?.lessons || [];
+
const events = lessonsArray.map((lesson) => {
const start = lesson.start_time || lesson.start;
const end = lesson.end_time || lesson.end || start;
- const startTimeStr = start ? new Date(start).toLocaleTimeString('ru-RU', {
- hour: '2-digit',
- minute: '2-digit',
- hourCycle: 'h23'
- }) : '';
-
+ const startTimeStr = start
+ ? new Date(start).toLocaleTimeString('ru-RU', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ })
+ : '';
+
const subject = lesson.subject_name || lesson.subject || 'Урок';
const student = lesson.client_name || '';
const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`;
@@ -83,14 +101,16 @@ export function useGetEvents() {
// ----------------------------------------------------------------------
export function useGetStudents() {
- const { data: response, isLoading, error } = useSWR(STUDENTS_ENDPOINT, getMentorStudents, swrOptions);
+ const { data: response, isLoading, error } = useSWR(
+ STUDENTS_ENDPOINT,
+ getMentorStudents,
+ swrOptions
+ );
return useMemo(() => {
const rawData = response?.data?.results || response?.results || response || [];
- const studentsArray = Array.isArray(rawData) ? rawData : [];
-
return {
- students: studentsArray,
+ students: Array.isArray(rawData) ? rawData : [],
studentsLoading: isLoading,
studentsError: error,
};
@@ -98,14 +118,16 @@ export function useGetStudents() {
}
export function useGetSubjects() {
- const { data: response, isLoading, error } = useSWR(SUBJECTS_ENDPOINT, getMentorSubjects, swrOptions);
+ const { data: response, isLoading, error } = useSWR(
+ SUBJECTS_ENDPOINT,
+ getMentorSubjects,
+ swrOptions
+ );
return useMemo(() => {
const rawData = response?.data || response?.results || response || [];
- const subjectsArray = Array.isArray(rawData) ? rawData : [];
-
return {
- subjects: subjectsArray,
+ subjects: Array.isArray(rawData) ? rawData : [],
subjectsLoading: isLoading,
subjectsError: error,
};
@@ -114,25 +136,53 @@ export function useGetSubjects() {
// ----------------------------------------------------------------------
-export async function createEvent(eventData) {
- const payload = {
- client: String(eventData.client),
- title: eventData.title.replace(' - ', ' — '),
- description: eventData.description,
- start_time: eventData.start_time,
- duration: eventData.duration,
- price: eventData.price,
- is_recurring: eventData.is_recurring,
- subject_id: Number(eventData.subject),
- };
-
- const response = await createCalendarLesson(payload);
-
- // Обновляем кэш, чтобы занятия появлялись сразу
- mutate([CALENDAR_ENDPOINT, '2026-02-01', '2026-04-30']);
-
- return response;
+function revalidateCalendar(date) {
+ const d = date || new Date();
+ const start = format(startOfMonth(subMonths(d, 1)), 'yyyy-MM-dd');
+ const end = format(endOfMonth(addMonths(d, 1)), 'yyyy-MM-dd');
+ mutate(['calendar', start, end]);
}
-export async function updateEvent(eventData) { console.log('Update Event:', eventData); }
-export async function deleteEvent(eventId) { console.log('Delete Event:', eventId); }
+export async function createEvent(eventData, currentDate) {
+ const startTime = new Date(eventData.start_time);
+ const endTime = new Date(startTime.getTime() + (eventData.duration || 60) * 60000);
+
+ const payload = {
+ client: String(eventData.client),
+ title: eventData.title || 'Занятие',
+ description: eventData.description || '',
+ start_time: startTime.toISOString(),
+ end_time: endTime.toISOString(),
+ price: eventData.price,
+ is_recurring: eventData.is_recurring || false,
+ ...(eventData.subject && { subject_id: Number(eventData.subject) }),
+ };
+
+ const res = await createCalendarLesson(payload);
+ revalidateCalendar(currentDate);
+ return res;
+}
+
+export async function updateEvent(eventData, currentDate) {
+ const { id, ...data } = eventData;
+
+ const updatePayload = {};
+ if (data.start_time) {
+ const startTime = new Date(data.start_time);
+ const endTime = new Date(startTime.getTime() + (data.duration || 60) * 60000);
+ updatePayload.start_time = startTime.toISOString();
+ updatePayload.end_time = endTime.toISOString();
+ }
+ if (data.price != null) updatePayload.price = data.price;
+ if (data.description != null) updatePayload.description = data.description;
+ if (data.status) updatePayload.status = data.status;
+
+ const res = await updateCalendarLesson(String(id), updatePayload);
+ revalidateCalendar(currentDate);
+ return res;
+}
+
+export async function deleteEvent(eventId, deleteAllFuture = false, currentDate) {
+ await deleteCalendarLesson(String(eventId), deleteAllFuture);
+ revalidateCalendar(currentDate);
+}
diff --git a/front_minimal/src/app.jsx b/front_minimal/src/app.jsx
new file mode 100644
index 0000000..d204ec5
--- /dev/null
+++ b/front_minimal/src/app.jsx
@@ -0,0 +1,43 @@
+import { BrowserRouter } from 'react-router-dom';
+
+import { LocalizationProvider } from 'src/locales';
+import { I18nProvider } from 'src/locales/i18n-provider';
+import { ThemeProvider } from 'src/theme/theme-provider';
+
+import { Snackbar } from 'src/components/snackbar';
+import { ProgressBar } from 'src/components/progress-bar';
+import { MotionLazy } from 'src/components/animate/motion-lazy';
+import { SettingsDrawer, defaultSettings, SettingsProvider } from 'src/components/settings';
+
+import { CheckoutProvider } from 'src/sections/checkout/context';
+
+import { AuthProvider } from 'src/auth/context/jwt';
+
+import { Router } from 'src/routes/sections';
+
+// ----------------------------------------------------------------------
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/front_minimal/src/app/auth/jwt/forgot-password/layout.jsx b/front_minimal/src/app/auth/jwt/forgot-password/layout.jsx
new file mode 100644
index 0000000..0b03c07
--- /dev/null
+++ b/front_minimal/src/app/auth/jwt/forgot-password/layout.jsx
@@ -0,0 +1,13 @@
+import { AuthSplitLayout } from 'src/layouts/auth-split';
+
+import { GuestGuard } from 'src/auth/guard';
+
+// ----------------------------------------------------------------------
+
+export default function Layout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/front_minimal/src/app/auth/jwt/forgot-password/page.jsx b/front_minimal/src/app/auth/jwt/forgot-password/page.jsx
new file mode 100644
index 0000000..ea42561
--- /dev/null
+++ b/front_minimal/src/app/auth/jwt/forgot-password/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { JwtForgotPasswordView } from 'src/sections/auth/jwt';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Forgot password | Jwt - ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/auth/jwt/reset-password/layout.jsx b/front_minimal/src/app/auth/jwt/reset-password/layout.jsx
new file mode 100644
index 0000000..0b03c07
--- /dev/null
+++ b/front_minimal/src/app/auth/jwt/reset-password/layout.jsx
@@ -0,0 +1,13 @@
+import { AuthSplitLayout } from 'src/layouts/auth-split';
+
+import { GuestGuard } from 'src/auth/guard';
+
+// ----------------------------------------------------------------------
+
+export default function Layout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/front_minimal/src/app/auth/jwt/reset-password/page.jsx b/front_minimal/src/app/auth/jwt/reset-password/page.jsx
new file mode 100644
index 0000000..84da173
--- /dev/null
+++ b/front_minimal/src/app/auth/jwt/reset-password/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { JwtResetPasswordView } from 'src/sections/auth/jwt';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Reset password | Jwt - ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/auth/jwt/verify-email/layout.jsx b/front_minimal/src/app/auth/jwt/verify-email/layout.jsx
new file mode 100644
index 0000000..3a3648c
--- /dev/null
+++ b/front_minimal/src/app/auth/jwt/verify-email/layout.jsx
@@ -0,0 +1,7 @@
+import { AuthSplitLayout } from 'src/layouts/auth-split';
+
+// ----------------------------------------------------------------------
+
+export default function Layout({ children }) {
+ return {children};
+}
diff --git a/front_minimal/src/app/auth/jwt/verify-email/page.jsx b/front_minimal/src/app/auth/jwt/verify-email/page.jsx
new file mode 100644
index 0000000..0d656bb
--- /dev/null
+++ b/front_minimal/src/app/auth/jwt/verify-email/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { JwtVerifyEmailView } from 'src/sections/auth/jwt';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Verify email | Jwt - ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/analytics/page.jsx b/front_minimal/src/app/dashboard/analytics/page.jsx
index 9ffebeb..321da4c 100644
--- a/front_minimal/src/app/dashboard/analytics/page.jsx
+++ b/front_minimal/src/app/dashboard/analytics/page.jsx
@@ -1,11 +1,11 @@
import { CONFIG } from 'src/config-global';
-import { OverviewAnalyticsView } from 'src/sections/overview/analytics/view';
+import { AnalyticsView } from 'src/sections/analytics/view';
// ----------------------------------------------------------------------
-export const metadata = { title: `Analytics | Dashboard - ${CONFIG.site.name}` };
+export const metadata = { title: `Аналитика | ${CONFIG.site.name}` };
export default function Page() {
- return ;
+ return ;
}
diff --git a/front_minimal/src/app/dashboard/board/page.jsx b/front_minimal/src/app/dashboard/board/page.jsx
new file mode 100644
index 0000000..b3c945a
--- /dev/null
+++ b/front_minimal/src/app/dashboard/board/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { BoardView } from 'src/sections/board/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Доска | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/chat-platform/page.jsx b/front_minimal/src/app/dashboard/chat-platform/page.jsx
new file mode 100644
index 0000000..7b7b1be
--- /dev/null
+++ b/front_minimal/src/app/dashboard/chat-platform/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { ChatPlatformView } from 'src/sections/chat/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Чат | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/children-progress/page.jsx b/front_minimal/src/app/dashboard/children-progress/page.jsx
new file mode 100644
index 0000000..e905e30
--- /dev/null
+++ b/front_minimal/src/app/dashboard/children-progress/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { ChildrenProgressView } from 'src/sections/children/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Прогресс ребёнка | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/children/page.jsx b/front_minimal/src/app/dashboard/children/page.jsx
new file mode 100644
index 0000000..fbe635e
--- /dev/null
+++ b/front_minimal/src/app/dashboard/children/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { ChildrenView } from 'src/sections/children/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Мои дети | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/feedback/page.jsx b/front_minimal/src/app/dashboard/feedback/page.jsx
new file mode 100644
index 0000000..64db4e6
--- /dev/null
+++ b/front_minimal/src/app/dashboard/feedback/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { FeedbackView } from 'src/sections/feedback/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Обратная связь | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/my-progress/page.jsx b/front_minimal/src/app/dashboard/my-progress/page.jsx
new file mode 100644
index 0000000..28b8e6f
--- /dev/null
+++ b/front_minimal/src/app/dashboard/my-progress/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { MyProgressView } from 'src/sections/my-progress/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Мой прогресс | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/page.jsx b/front_minimal/src/app/dashboard/page.jsx
index 6631cd8..b153a2b 100644
--- a/front_minimal/src/app/dashboard/page.jsx
+++ b/front_minimal/src/app/dashboard/page.jsx
@@ -1,38 +1,65 @@
'use client';
-import { CONFIG } from 'src/config-global';
+import { useState, useEffect } from 'react';
+
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import CircularProgress from '@mui/material/CircularProgress';
+
import { useAuthContext } from 'src/auth/hooks';
-// Временно импортируем только ментора (позже добавим клиента и родителя)
import { OverviewCourseView } from 'src/sections/overview/course/view';
+import { OverviewClientView } from 'src/sections/overview/client/view';
-export default function Page() {
+// ----------------------------------------------------------------------
+
+export default function DashboardPage() {
const { user, loading } = useAuthContext();
+ // Для родителя: выбранный ребёнок из localStorage
+ const [selectedChild, setSelectedChild] = useState(null);
+
+ useEffect(() => {
+ if (user?.role === 'parent') {
+ try {
+ const saved = localStorage.getItem('selected_child');
+ if (saved) setSelectedChild(JSON.parse(saved));
+ } catch {
+ // ignore
+ }
+ }
+ }, [user]);
+
if (loading) {
- return
Загрузка...
;
+ return (
+
+
+
+ );
}
- if (!user) {
- return null;
- }
+ if (!user) return null;
- // Роутинг по ролям
if (user.role === 'mentor') {
return ;
}
-
+
if (user.role === 'client') {
- return Дашборд Клиента (в разработке)
;
+ return ;
}
-
+
if (user.role === 'parent') {
- return Дашборд Родителя (в разработке)
;
+ return (
+
+ );
}
return (
-
-
Неизвестная роль пользователя: {user.role}
-
+
+ Неизвестная роль: {user.role}
+
);
}
diff --git a/front_minimal/src/app/dashboard/payment-platform/page.jsx b/front_minimal/src/app/dashboard/payment-platform/page.jsx
new file mode 100644
index 0000000..8e195ad
--- /dev/null
+++ b/front_minimal/src/app/dashboard/payment-platform/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { PaymentPlatformView } from 'src/sections/payment/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Оплата | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/profile/page.jsx b/front_minimal/src/app/dashboard/profile/page.jsx
new file mode 100644
index 0000000..ec0d4f7
--- /dev/null
+++ b/front_minimal/src/app/dashboard/profile/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { AccountPlatformView } from 'src/sections/account-platform/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Профиль | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/dashboard/referrals/page.jsx b/front_minimal/src/app/dashboard/referrals/page.jsx
new file mode 100644
index 0000000..21593cd
--- /dev/null
+++ b/front_minimal/src/app/dashboard/referrals/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { ReferralsView } from 'src/sections/referrals/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Рефералы | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/app/video-call/layout.jsx b/front_minimal/src/app/video-call/layout.jsx
new file mode 100644
index 0000000..db7d052
--- /dev/null
+++ b/front_minimal/src/app/video-call/layout.jsx
@@ -0,0 +1,5 @@
+// Fullscreen layout — no sidebar or header
+
+export default function VideoCallLayout({ children }) {
+ return children;
+}
diff --git a/front_minimal/src/app/video-call/page.jsx b/front_minimal/src/app/video-call/page.jsx
new file mode 100644
index 0000000..aa3eefb
--- /dev/null
+++ b/front_minimal/src/app/video-call/page.jsx
@@ -0,0 +1,11 @@
+import { CONFIG } from 'src/config-global';
+
+import { VideoCallView } from 'src/sections/video-call/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Видеозвонок | ${CONFIG.site.name}` };
+
+export default function Page() {
+ return ;
+}
diff --git a/front_minimal/src/auth/context/jwt/action.js b/front_minimal/src/auth/context/jwt/action.js
index fc5ba4f..db02f7d 100644
--- a/front_minimal/src/auth/context/jwt/action.js
+++ b/front_minimal/src/auth/context/jwt/action.js
@@ -3,25 +3,24 @@
import axios, { endpoints } from 'src/utils/axios';
import { setSession } from './utils';
-import { STORAGE_KEY } from './constant';
+import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
/** **************************************
* Sign in
*************************************** */
export const signInWithPassword = async ({ email, password }) => {
try {
- const params = { email, password };
+ const res = await axios.post(endpoints.auth.signIn, { email, password });
- const res = await axios.post(endpoints.auth.signIn, params);
-
- // Адаптация под твой API: { data: { tokens: { access } } }
- const accessToken = res.data?.data?.tokens?.access;
+ const data = res.data?.data;
+ const accessToken = data?.tokens?.access;
+ const refreshToken = data?.tokens?.refresh;
if (!accessToken) {
throw new Error('Access token not found in response');
}
- setSession(accessToken);
+ await setSession(accessToken, refreshToken);
} catch (error) {
console.error('Error during sign in:', error);
throw error;
@@ -31,24 +30,32 @@ export const signInWithPassword = async ({ email, password }) => {
/** **************************************
* Sign up
*************************************** */
-export const signUp = async ({ email, password, firstName, lastName }) => {
- const params = {
- email,
- password,
- firstName,
- lastName,
- };
-
+export const signUp = async ({ email, password, passwordConfirm, firstName, lastName, role, city, timezone }) => {
try {
+ const params = {
+ email,
+ password,
+ password_confirm: passwordConfirm,
+ first_name: firstName,
+ last_name: lastName,
+ role: role || 'client',
+ city: city || '',
+ timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
+ };
+
const res = await axios.post(endpoints.auth.signUp, params);
- const { accessToken } = res.data;
+ const data = res.data?.data;
+ const accessToken = data?.tokens?.access;
+ const refreshToken = data?.tokens?.refresh;
if (!accessToken) {
- throw new Error('Access token not found in response');
+ // Регистрация прошла, но токен не выдан (требуется верификация email)
+ return { requiresVerification: true };
}
- sessionStorage.setItem(STORAGE_KEY, accessToken);
+ await setSession(accessToken, refreshToken);
+ return { requiresVerification: false };
} catch (error) {
console.error('Error during sign up:', error);
throw error;
@@ -66,3 +73,67 @@ export const signOut = async () => {
throw error;
}
};
+
+/** **************************************
+ * Refresh token
+ *************************************** */
+export const refreshAccessToken = async () => {
+ try {
+ const refreshToken = localStorage.getItem(REFRESH_STORAGE_KEY);
+ if (!refreshToken) throw new Error('No refresh token');
+
+ const res = await axios.post(endpoints.auth.refresh, { refresh: refreshToken }, {
+ headers: { Authorization: undefined },
+ });
+
+ const accessToken = res.data?.access;
+ if (!accessToken) throw new Error('No access token in refresh response');
+
+ await setSession(accessToken, refreshToken);
+ return accessToken;
+ } catch (error) {
+ console.error('Error during token refresh:', error);
+ throw error;
+ }
+};
+
+/** **************************************
+ * Request password reset
+ *************************************** */
+export const requestPasswordReset = async ({ email }) => {
+ try {
+ await axios.post(endpoints.auth.passwordReset, { email });
+ } catch (error) {
+ console.error('Error during password reset request:', error);
+ throw error;
+ }
+};
+
+/** **************************************
+ * Confirm password reset
+ *************************************** */
+export const confirmPasswordReset = async ({ token, newPassword, newPasswordConfirm }) => {
+ try {
+ await axios.post(endpoints.auth.passwordResetConfirm, {
+ token,
+ new_password: newPassword,
+ new_password_confirm: newPasswordConfirm,
+ });
+ } catch (error) {
+ console.error('Error during password reset confirm:', error);
+ throw error;
+ }
+};
+
+/** **************************************
+ * Verify email
+ *************************************** */
+export const verifyEmail = async ({ token }) => {
+ try {
+ const res = await axios.post(endpoints.auth.verifyEmail, { token });
+ return res.data;
+ } catch (error) {
+ console.error('Error during email verification:', error);
+ throw error;
+ }
+};
diff --git a/front_minimal/src/auth/context/jwt/auth-provider.jsx b/front_minimal/src/auth/context/jwt/auth-provider.jsx
index 43645f9..ecc4506 100644
--- a/front_minimal/src/auth/context/jwt/auth-provider.jsx
+++ b/front_minimal/src/auth/context/jwt/auth-provider.jsx
@@ -3,9 +3,10 @@
import { useMemo, useEffect, useCallback } from 'react';
import { useSetState } from 'src/hooks/use-set-state';
import axios, { endpoints } from 'src/utils/axios';
-import { STORAGE_KEY } from './constant';
+import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
import { AuthContext } from '../auth-context';
import { setSession, isValidToken } from './utils';
+import { refreshAccessToken } from './action';
export function AuthProvider({ children }) {
const { state, setState } = useSetState({
@@ -15,25 +16,28 @@ export function AuthProvider({ children }) {
const checkUserSession = useCallback(async () => {
try {
- const accessToken = sessionStorage.getItem(STORAGE_KEY);
+ let accessToken = localStorage.getItem(STORAGE_KEY);
if (accessToken && isValidToken(accessToken)) {
setSession(accessToken);
-
- const res = await axios.get(endpoints.auth.me);
-
- // Гарантируем получение объекта пользователя из data
- const userData = res.data?.data || res.data;
-
- // Если прилетел массив или невалидный объект - сбрасываем
- if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
- throw new Error('Invalid user data format');
- }
-
- setState({ user: { ...userData, accessToken }, loading: false });
} else {
- setState({ user: null, loading: false });
+ // Пробуем обновить через refresh token
+ try {
+ accessToken = await refreshAccessToken();
+ } catch {
+ setState({ user: null, loading: false });
+ return;
+ }
}
+
+ const res = await axios.get(endpoints.auth.me);
+ const userData = res.data?.data || res.data;
+
+ if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
+ throw new Error('Invalid user data format');
+ }
+
+ setState({ user: { ...userData, accessToken }, loading: false });
} catch (error) {
console.error('[Auth Debug]:', error);
setState({ user: null, loading: false });
@@ -52,7 +56,7 @@ export function AuthProvider({ children }) {
user: state.user
? {
...state.user,
- role: state.user?.role ?? 'admin',
+ role: state.user?.role ?? 'client',
}
: null,
checkUserSession,
diff --git a/front_minimal/src/auth/context/jwt/constant.js b/front_minimal/src/auth/context/jwt/constant.js
index 14d75cb..052b067 100644
--- a/front_minimal/src/auth/context/jwt/constant.js
+++ b/front_minimal/src/auth/context/jwt/constant.js
@@ -1 +1,2 @@
export const STORAGE_KEY = 'jwt_access_token';
+export const REFRESH_STORAGE_KEY = 'jwt_refresh_token';
diff --git a/front_minimal/src/auth/context/jwt/utils.js b/front_minimal/src/auth/context/jwt/utils.js
index ed72a61..18e7653 100644
--- a/front_minimal/src/auth/context/jwt/utils.js
+++ b/front_minimal/src/auth/context/jwt/utils.js
@@ -2,7 +2,7 @@ import { paths } from 'src/routes/paths';
import axios from 'src/utils/axios';
-import { STORAGE_KEY } from './constant';
+import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
// ----------------------------------------------------------------------
@@ -57,26 +57,29 @@ export function tokenExpired(exp) {
setTimeout(() => {
try {
- alert('Token expired!');
- sessionStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(REFRESH_STORAGE_KEY);
window.location.href = paths.auth.jwt.signIn;
} catch (error) {
console.error('Error during token expiration:', error);
- throw error;
}
}, timeLeft);
}
// ----------------------------------------------------------------------
-export async function setSession(accessToken) {
+export async function setSession(accessToken, refreshToken) {
try {
if (accessToken) {
- sessionStorage.setItem(STORAGE_KEY, accessToken);
+ localStorage.setItem(STORAGE_KEY, accessToken);
+
+ if (refreshToken) {
+ localStorage.setItem(REFRESH_STORAGE_KEY, refreshToken);
+ }
axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
- const decodedToken = jwtDecode(accessToken); // ~3 days by minimals server
+ const decodedToken = jwtDecode(accessToken);
if (decodedToken && 'exp' in decodedToken) {
tokenExpired(decodedToken.exp);
@@ -84,7 +87,8 @@ export async function setSession(accessToken) {
throw new Error('Invalid access token!');
}
} else {
- sessionStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(REFRESH_STORAGE_KEY);
delete axios.defaults.headers.common.Authorization;
}
} catch (error) {
diff --git a/front_minimal/src/components/chart/chart.jsx b/front_minimal/src/components/chart/chart.jsx
index 94bee23..901a776 100644
--- a/front_minimal/src/components/chart/chart.jsx
+++ b/front_minimal/src/components/chart/chart.jsx
@@ -1,21 +1,10 @@
-import dynamic from 'next/dynamic';
+import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
-import { withLoadingProps } from 'src/utils/with-loading-props';
-
import { ChartLoading } from './chart-loading';
-const ApexChart = withLoadingProps((props) =>
- dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
- ssr: false,
- loading: () => {
- const { loading, type } = props();
-
- return loading?.disabled ? null : ;
- },
- })
-);
+const ApexChart = lazy(() => import('react-apexcharts').then((mod) => ({ default: mod.default })));
// ----------------------------------------------------------------------
@@ -42,14 +31,21 @@ export function Chart({
}}
{...other}
>
-
+
+ )
+ }
+ >
+
+
);
}
diff --git a/front_minimal/src/components/organizational-chart/organizational-chart.jsx b/front_minimal/src/components/organizational-chart/organizational-chart.jsx
index 2dc18bb..2c5e27b 100644
--- a/front_minimal/src/components/organizational-chart/organizational-chart.jsx
+++ b/front_minimal/src/components/organizational-chart/organizational-chart.jsx
@@ -1,5 +1,4 @@
-import dynamic from 'next/dynamic';
-import { cloneElement } from 'react';
+import { lazy, Suspense, cloneElement } from 'react';
import { useTheme } from '@mui/material/styles';
@@ -7,13 +6,13 @@ import { flattenArray } from 'src/utils/helper';
// ----------------------------------------------------------------------
-const Tree = dynamic(() => import('react-organizational-chart').then((mod) => mod.Tree), {
- ssr: false,
-});
+const Tree = lazy(() =>
+ import('react-organizational-chart').then((mod) => ({ default: mod.Tree }))
+);
-const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), {
- ssr: false,
-});
+const TreeNode = lazy(() =>
+ import('react-organizational-chart').then((mod) => ({ default: mod.TreeNode }))
+);
// ----------------------------------------------------------------------
@@ -27,18 +26,20 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
});
return (
-
- {data.children.map((list, index) => (
-
- ))}
-
+
+
+ {data.children.map((list, index) => (
+
+ ))}
+
+
);
}
diff --git a/front_minimal/src/components/settings/server.js b/front_minimal/src/components/settings/server.js
index 0cab146..263d6c0 100644
--- a/front_minimal/src/components/settings/server.js
+++ b/front_minimal/src/components/settings/server.js
@@ -1,13 +1,6 @@
-import { cookies } from 'next/headers';
-
-import { STORAGE_KEY, defaultSettings } from './config-settings';
-
-// ----------------------------------------------------------------------
+// Stub — server-side functions not used in Vite SPA
+// Settings are always read from localStorage
export async function detectSettings() {
- const cookieStore = cookies();
-
- const settingsStore = cookieStore.get(STORAGE_KEY);
-
- return settingsStore ? JSON.parse(settingsStore?.value) : defaultSettings;
+ return undefined;
}
diff --git a/front_minimal/src/components/walktour/walktour.jsx b/front_minimal/src/components/walktour/walktour.jsx
index e665d07..7b31cbb 100644
--- a/front_minimal/src/components/walktour/walktour.jsx
+++ b/front_minimal/src/components/walktour/walktour.jsx
@@ -1,4 +1,4 @@
-import dynamic from 'next/dynamic';
+import { lazy, Suspense } from 'react';
import { useTheme } from '@mui/material/styles';
@@ -8,9 +8,7 @@ import { WalktourTooltip } from './walktour-tooltip';
// ----------------------------------------------------------------------
-const Joyride = dynamic(() => import('react-joyride').then((mod) => mod.default), {
- ssr: false,
-});
+const Joyride = lazy(() => import('react-joyride').then((mod) => ({ default: mod.default })));
// ----------------------------------------------------------------------
@@ -32,6 +30,7 @@ export function Walktour({
};
return (
+
+
);
}
diff --git a/front_minimal/src/config-global.js b/front_minimal/src/config-global.js
index 0ccd068..fd3835b 100644
--- a/front_minimal/src/config-global.js
+++ b/front_minimal/src/config-global.js
@@ -6,16 +6,17 @@ import packageJson from '../package.json';
export const CONFIG = {
site: {
- name: 'Minimals',
+ name: 'Platform',
serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '',
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '',
version: packageJson.version,
},
- isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`),
+ // В Next.js всегда работаем как SPA (без SSR для наших страниц)
+ isStaticExport: true,
/**
* Auth
- * @method jwt | amplify | firebase | supabase | auth0
+ * @method jwt
*/
auth: {
method: 'jwt',
diff --git a/front_minimal/src/hooks/use-chat-websocket.js b/front_minimal/src/hooks/use-chat-websocket.js
new file mode 100644
index 0000000..cdee81d
--- /dev/null
+++ b/front_minimal/src/hooks/use-chat-websocket.js
@@ -0,0 +1,62 @@
+import { useRef, useState, useEffect, useCallback } from 'react';
+
+import { CONFIG } from 'src/config-global';
+
+// ----------------------------------------------------------------------
+
+export function useChatWebSocket({ chatUuid, enabled = true, onMessage }) {
+ const [isConnected, setIsConnected] = useState(false);
+ const wsRef = useRef(null);
+ const onMessageRef = useRef(onMessage);
+ onMessageRef.current = onMessage;
+
+ const disconnect = useCallback(() => {
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ setIsConnected(false);
+ }, []);
+
+ const connect = useCallback(() => {
+ if (!enabled || !chatUuid) return;
+ const token =
+ typeof window !== 'undefined'
+ ? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
+ : '';
+ if (!token) return;
+
+ const serverUrl = CONFIG.site.serverUrl || '';
+ let apiBase = serverUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
+ if (!apiBase) apiBase = typeof window !== 'undefined' ? window.location.origin : '';
+
+ const wsProtocol = apiBase.startsWith('https') ? 'wss:' : 'ws:';
+ const wsHost = apiBase.replace(/^https?:\/\//, '');
+ const wsUrl = `${wsProtocol}//${wsHost}/ws/chat/${chatUuid}/?token=${token}`;
+
+ const ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.onopen = () => setIsConnected(true);
+ ws.onclose = () => setIsConnected(false);
+ ws.onerror = () => setIsConnected(false);
+ ws.onmessage = (ev) => {
+ try {
+ const data = JSON.parse(ev.data);
+ if (data.type === 'chat_message' && data.message) {
+ onMessageRef.current?.(data.message);
+ }
+ } catch (_e) {
+ // ignore parse errors
+ }
+ };
+ }, [chatUuid, enabled]);
+
+ useEffect(() => {
+ disconnect();
+ connect();
+ return () => disconnect();
+ }, [connect, disconnect]);
+
+ return { isConnected };
+}
diff --git a/front_minimal/src/layouts/config-nav-dashboard.jsx b/front_minimal/src/layouts/config-nav-dashboard.jsx
index f4c7c22..7c4737e 100644
--- a/front_minimal/src/layouts/config-nav-dashboard.jsx
+++ b/front_minimal/src/layouts/config-nav-dashboard.jsx
@@ -14,32 +14,65 @@ const ICONS = {
dashboard: icon('ic-dashboard'),
kanban: icon('ic-kanban'),
folder: icon('ic-folder'),
+ analytics: icon('ic-analytics'),
+ label: icon('ic-label'),
};
// ----------------------------------------------------------------------
-export const navData = [
- /**
- * Основное
- */
- {
- subheader: 'Главная',
- items: [
- { title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard },
- ],
- },
- /**
- * Управление
- */
- {
- subheader: 'Инструменты',
- items: [
- { title: 'Ученики', path: paths.dashboard.students, icon: ICONS.user },
- { title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar },
- { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban },
- { title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder },
- { title: 'Чат', path: paths.dashboard.chat, icon: ICONS.chat },
- { title: 'Уведомления', path: paths.dashboard.notifications, icon: icon('ic-label') },
- ],
- },
-];
+export function getNavData(role) {
+ const isMentor = role === 'mentor';
+ const isClient = role === 'client';
+ const isParent = role === 'parent';
+
+ return [
+ {
+ subheader: 'Главная',
+ items: [
+ { title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard },
+ ],
+ },
+ {
+ subheader: 'Инструменты',
+ items: [
+ // Ученики/Менторы — для всех ролей (разный контент внутри)
+ { title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user },
+ { title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar },
+ { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban },
+ { title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder },
+ { title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban },
+ { title: 'Чат', path: paths.dashboard.chatPlatform, icon: ICONS.chat },
+ { title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.label },
+
+ // Ментор-специфичные
+ ...(isMentor ? [
+ { title: 'Аналитика', path: paths.dashboard.analytics, icon: ICONS.analytics },
+ { title: 'Обратная связь', path: paths.dashboard.feedback, icon: icon('ic-label') },
+ ] : []),
+
+ // Клиент/Родитель
+ ...((isClient || isParent) ? [
+ { title: 'Мой прогресс', path: paths.dashboard.myProgress, icon: ICONS.course },
+ ] : []),
+
+ // Родитель-специфичные
+ ...(isParent ? [
+ { title: 'Дети', path: paths.dashboard.children, icon: ICONS.user },
+ { title: 'Прогресс детей', path: paths.dashboard.childrenProgress, icon: ICONS.course },
+ ] : []),
+
+ { title: 'Оплата', path: paths.dashboard.payment, icon: ICONS.folder },
+ ],
+ },
+ {
+ subheader: 'Аккаунт',
+ items: [
+ { title: 'Профиль', path: paths.dashboard.profile, icon: ICONS.user },
+ { title: 'Рефералы', path: paths.dashboard.referrals, icon: ICONS.course },
+ ],
+ },
+ ];
+}
+
+// Обратная совместимость — статический nav для случаев без роли
+export const navData = getNavData('mentor');
diff --git a/front_minimal/src/layouts/dashboard/layout.jsx b/front_minimal/src/layouts/dashboard/layout.jsx
index 549c783..8744d47 100644
--- a/front_minimal/src/layouts/dashboard/layout.jsx
+++ b/front_minimal/src/layouts/dashboard/layout.jsx
@@ -24,7 +24,8 @@ import { _account } from '../config-nav-account';
import { HeaderBase } from '../core/header-base';
import { _workspaces } from '../config-nav-workspace';
import { LayoutSection } from '../core/layout-section';
-import { navData as dashboardNavData } from '../config-nav-dashboard';
+import { getNavData } from '../config-nav-dashboard';
+import { useAuthContext } from 'src/auth/hooks';
// ----------------------------------------------------------------------
@@ -35,11 +36,13 @@ export function DashboardLayout({ sx, children, data }) {
const settings = useSettingsContext();
+ const { user } = useAuthContext();
+
const navColorVars = useNavColorVars(theme, settings);
const layoutQuery = 'lg';
- const navData = data?.nav ?? dashboardNavData;
+ const navData = data?.nav ?? getNavData(user?.role);
const isNavMini = settings.navLayout === 'mini';
diff --git a/front_minimal/src/locales/server.js b/front_minimal/src/locales/server.js
index f445e50..aae45f0 100644
--- a/front_minimal/src/locales/server.js
+++ b/front_minimal/src/locales/server.js
@@ -1,51 +1,6 @@
-import { cache } from 'react';
-import { createInstance } from 'i18next';
-import { cookies as getCookies } from 'next/headers';
-import resourcesToBackend from 'i18next-resources-to-backend';
-import { initReactI18next } from 'react-i18next/initReactI18next';
-
-import { defaultNS, cookieName, i18nOptions, fallbackLng } from './config-locales';
-
-// ----------------------------------------------------------------------
-
-/**
- * [1] with url:
- * https://nextjs.org/docs/pages/building-your-application/routing/internationalization
- *
- * Use i18next with app folder and without locale in url:
- * https://github.com/i18next/next-app-dir-i18next-example/issues/12#issuecomment-1500917570
- */
+// Stub — server-side functions not used in Vite SPA
+// Language detection is handled by i18n-provider via localStorage
export async function detectLanguage() {
- const cookies = getCookies();
-
- const language = cookies.get(cookieName)?.value ?? fallbackLng;
-
- return language;
+ return undefined;
}
-
-// ----------------------------------------------------------------------
-
-export const getServerTranslations = cache(async (ns = defaultNS, options = {}) => {
- const language = await detectLanguage();
-
- const i18nextInstance = await initServerI18next(language, ns);
-
- return {
- t: i18nextInstance.getFixedT(language, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
- i18n: i18nextInstance,
- };
-});
-
-// ----------------------------------------------------------------------
-
-const initServerI18next = async (language, namespace) => {
- const i18nInstance = createInstance();
-
- await i18nInstance
- .use(initReactI18next)
- .use(resourcesToBackend((lang, ns) => import(`./langs/${lang}/${ns}.json`)))
- .init(i18nOptions(language, namespace));
-
- return i18nInstance;
-};
diff --git a/front_minimal/src/main.jsx b/front_minimal/src/main.jsx
new file mode 100644
index 0000000..b1fcead
--- /dev/null
+++ b/front_minimal/src/main.jsx
@@ -0,0 +1,16 @@
+import './global.css';
+
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import App from './app';
+
+// ----------------------------------------------------------------------
+
+const root = createRoot(document.getElementById('root'));
+
+root.render(
+
+
+
+);
diff --git a/front_minimal/src/routes/paths.js b/front_minimal/src/routes/paths.js
index 0e8372f..4905505 100644
--- a/front_minimal/src/routes/paths.js
+++ b/front_minimal/src/routes/paths.js
@@ -17,6 +17,7 @@ const ROOTS = {
// ----------------------------------------------------------------------
export const paths = {
+ videoCall: '/video-call',
comingSoon: '/coming-soon',
maintenance: '/maintenance',
pricing: '/pricing',
@@ -106,6 +107,16 @@ export const paths = {
materials: `${ROOTS.DASHBOARD}/materials`,
students: `${ROOTS.DASHBOARD}/students`,
notifications: `${ROOTS.DASHBOARD}/notifications`,
+ board: `${ROOTS.DASHBOARD}/board`,
+ referrals: `${ROOTS.DASHBOARD}/referrals`,
+ profile: `${ROOTS.DASHBOARD}/profile`,
+ children: `${ROOTS.DASHBOARD}/children`,
+ childrenProgress: `${ROOTS.DASHBOARD}/children-progress`,
+ myProgress: `${ROOTS.DASHBOARD}/my-progress`,
+ payment: `${ROOTS.DASHBOARD}/payment-platform`,
+ chatPlatform: `${ROOTS.DASHBOARD}/chat-platform`,
+ analytics: `${ROOTS.DASHBOARD}/analytics`,
+ feedback: `${ROOTS.DASHBOARD}/feedback`,
fileManager: `${ROOTS.DASHBOARD}/file-manager`,
permission: `${ROOTS.DASHBOARD}/permission`,
general: {
diff --git a/front_minimal/src/routes/sections.jsx b/front_minimal/src/routes/sections.jsx
new file mode 100644
index 0000000..12fe5a8
--- /dev/null
+++ b/front_minimal/src/routes/sections.jsx
@@ -0,0 +1,691 @@
+import { lazy, Suspense } from 'react';
+import { Navigate, useRoutes, Outlet } from 'react-router-dom';
+
+import { AuthGuard } from 'src/auth/guard/auth-guard';
+import { GuestGuard } from 'src/auth/guard/guest-guard';
+
+import { AuthSplitLayout } from 'src/layouts/auth-split';
+import { DashboardLayout } from 'src/layouts/dashboard';
+
+import { SplashScreen } from 'src/components/loading-screen';
+
+// ----------------------------------------------------------------------
+// Auth - JWT
+
+const JwtSignInView = lazy(() =>
+ import('src/sections/auth/jwt/jwt-sign-in-view').then((m) => ({ default: m.JwtSignInView }))
+);
+const JwtSignUpView = lazy(() =>
+ import('src/sections/auth/jwt/jwt-sign-up-view').then((m) => ({ default: m.JwtSignUpView }))
+);
+const JwtForgotPasswordView = lazy(() =>
+ import('src/sections/auth/jwt/jwt-forgot-password-view').then((m) => ({
+ default: m.JwtForgotPasswordView,
+ }))
+);
+const JwtResetPasswordView = lazy(() =>
+ import('src/sections/auth/jwt/jwt-reset-password-view').then((m) => ({
+ default: m.JwtResetPasswordView,
+ }))
+);
+const JwtVerifyEmailView = lazy(() =>
+ import('src/sections/auth/jwt/jwt-verify-email-view').then((m) => ({
+ default: m.JwtVerifyEmailView,
+ }))
+);
+
+// ----------------------------------------------------------------------
+// Dashboard - Overview
+
+const OverviewAnalyticsView = lazy(() =>
+ import('src/sections/overview/analytics/view').then((m) => ({
+ default: m.OverviewAnalyticsView,
+ }))
+);
+const OverviewEcommerceView = lazy(() =>
+ import('src/sections/overview/e-commerce/view').then((m) => ({
+ default: m.OverviewEcommerceView,
+ }))
+);
+const OverviewBankingView = lazy(() =>
+ import('src/sections/overview/banking/view').then((m) => ({ default: m.OverviewBankingView }))
+);
+const OverviewBookingView = lazy(() =>
+ import('src/sections/overview/booking/view').then((m) => ({ default: m.OverviewBookingView }))
+);
+const OverviewFileView = lazy(() =>
+ import('src/sections/overview/file/view').then((m) => ({ default: m.OverviewFileView }))
+);
+const OverviewCourseView = lazy(() =>
+ import('src/sections/overview/course/view').then((m) => ({ default: m.OverviewCourseView }))
+);
+
+// Dashboard - Features
+
+const CalendarView = lazy(() =>
+ import('src/sections/calendar/view').then((m) => ({ default: m.CalendarView }))
+);
+const ChatView = lazy(() =>
+ import('src/sections/chat/view').then((m) => ({ default: m.ChatView }))
+);
+const MailView = lazy(() =>
+ import('src/sections/mail/view').then((m) => ({ default: m.MailView }))
+);
+const KanbanView = lazy(() =>
+ import('src/sections/kanban/view').then((m) => ({ default: m.KanbanView }))
+);
+const FileManagerView = lazy(() =>
+ import('src/sections/file-manager/view').then((m) => ({ default: m.FileManagerView }))
+);
+const PermissionDeniedView = lazy(() =>
+ import('src/sections/permission/view').then((m) => ({ default: m.PermissionDeniedView }))
+);
+const BlankView = lazy(() =>
+ import('src/sections/blank/view').then((m) => ({ default: m.BlankView }))
+);
+
+// Dashboard - User
+
+const UserProfileView = lazy(() =>
+ import('src/sections/user/view').then((m) => ({ default: m.UserProfileView }))
+);
+const UserListView = lazy(() =>
+ import('src/sections/user/view').then((m) => ({ default: m.UserListView }))
+);
+const UserCardsView = lazy(() =>
+ import('src/sections/user/view').then((m) => ({ default: m.UserCardsView }))
+);
+const UserCreateView = lazy(() =>
+ import('src/sections/user/view').then((m) => ({ default: m.UserCreateView }))
+);
+const UserEditView = lazy(() =>
+ import('src/sections/user/view').then((m) => ({ default: m.UserEditView }))
+);
+const AccountView = lazy(() =>
+ import('src/sections/account/view').then((m) => ({ default: m.AccountView }))
+);
+
+// Dashboard - Product
+
+const ProductListView = lazy(() =>
+ import('src/sections/product/view').then((m) => ({ default: m.ProductListView }))
+);
+const ProductDetailsView = lazy(() =>
+ import('src/sections/product/view').then((m) => ({ default: m.ProductDetailsView }))
+);
+const ProductCreateView = lazy(() =>
+ import('src/sections/product/view').then((m) => ({ default: m.ProductCreateView }))
+);
+const ProductEditView = lazy(() =>
+ import('src/sections/product/view').then((m) => ({ default: m.ProductEditView }))
+);
+
+// Dashboard - Order
+
+const OrderListView = lazy(() =>
+ import('src/sections/order/view').then((m) => ({ default: m.OrderListView }))
+);
+const OrderDetailsView = lazy(() =>
+ import('src/sections/order/view').then((m) => ({ default: m.OrderDetailsView }))
+);
+
+// Dashboard - Invoice
+
+const InvoiceListView = lazy(() =>
+ import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceListView }))
+);
+const InvoiceDetailsView = lazy(() =>
+ import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceDetailsView }))
+);
+const InvoiceCreateView = lazy(() =>
+ import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceCreateView }))
+);
+const InvoiceEditView = lazy(() =>
+ import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceEditView }))
+);
+
+// Dashboard - Blog / Post
+
+const PostListView = lazy(() =>
+ import('src/sections/blog/view').then((m) => ({ default: m.PostListView }))
+);
+const PostCreateView = lazy(() =>
+ import('src/sections/blog/view').then((m) => ({ default: m.PostCreateView }))
+);
+const PostDetailsView = lazy(() =>
+ import('src/sections/blog/view').then((m) => ({ default: m.PostDetailsView }))
+);
+const PostEditView = lazy(() =>
+ import('src/sections/blog/view').then((m) => ({ default: m.PostEditView }))
+);
+
+// Dashboard - Job
+
+const JobListView = lazy(() =>
+ import('src/sections/job/view').then((m) => ({ default: m.JobListView }))
+);
+const JobDetailsView = lazy(() =>
+ import('src/sections/job/view').then((m) => ({ default: m.JobDetailsView }))
+);
+const JobCreateView = lazy(() =>
+ import('src/sections/job/view').then((m) => ({ default: m.JobCreateView }))
+);
+const JobEditView = lazy(() =>
+ import('src/sections/job/view').then((m) => ({ default: m.JobEditView }))
+);
+
+// Dashboard - Tour
+
+const TourListView = lazy(() =>
+ import('src/sections/tour/view').then((m) => ({ default: m.TourListView }))
+);
+const TourDetailsView = lazy(() =>
+ import('src/sections/tour/view').then((m) => ({ default: m.TourDetailsView }))
+);
+const TourCreateView = lazy(() =>
+ import('src/sections/tour/view').then((m) => ({ default: m.TourCreateView }))
+);
+const TourEditView = lazy(() =>
+ import('src/sections/tour/view').then((m) => ({ default: m.TourEditView }))
+);
+
+// Error pages
+
+const Page403 = lazy(() =>
+ import('src/sections/error/403-view').then((m) => ({ default: m.View403 }))
+);
+const Page404 = lazy(() =>
+ import('src/sections/error/not-found-view').then((m) => ({ default: m.NotFoundView }))
+);
+const Page500 = lazy(() =>
+ import('src/sections/error/500-view').then((m) => ({ default: m.View500 }))
+);
+
+// ----------------------------------------------------------------------
+
+function Loading() {
+ return ;
+}
+
+function DashboardLayoutWrapper() {
+ return (
+
+
+
+
+
+ );
+}
+
+function AuthLayoutWrapper() {
+ return (
+
+
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function Router() {
+ return useRoutes([
+ // Root redirect
+ {
+ path: '/',
+ element: ,
+ },
+
+ // Auth - JWT
+ {
+ path: 'auth/jwt',
+ element: ,
+ children: [
+ {
+ path: 'sign-in',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'sign-up',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'forgot-password',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'reset-password',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+
+ // Verify email — без GuestGuard
+ {
+ path: 'auth/jwt/verify-email',
+ element: (
+
+ }>
+
+
+
+ ),
+ },
+
+ // Dashboard
+ {
+ path: 'dashboard',
+ element: ,
+ children: [
+ { index: true, element: },
+
+ // Overview
+ {
+ path: 'analytics',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'ecommerce',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'banking',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'booking',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'file',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'course',
+ element: (
+ }>
+
+
+ ),
+ },
+
+ // Features
+ {
+ path: 'schedule',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'chat',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'mail',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'kanban',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'file-manager',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'permission',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'blank',
+ element: (
+ }>
+
+
+ ),
+ },
+
+ // User
+ {
+ path: 'user',
+ children: [
+ { index: true, element: },
+ {
+ path: 'profile',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'list',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'cards',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'new',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id/edit',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'account',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+
+ // Product
+ {
+ path: 'product',
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'new',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id/edit',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+
+ // Order
+ {
+ path: 'order',
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+
+ // Invoice
+ {
+ path: 'invoice',
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'new',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id/edit',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+
+ // Blog / Post
+ {
+ path: 'post',
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'new',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':title',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':title/edit',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+
+ // Job
+ {
+ path: 'job',
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'new',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id/edit',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+
+ // Tour
+ {
+ path: 'tour',
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'new',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: ':id/edit',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
+ },
+ ],
+ },
+
+ // Error pages
+ { path: '403', element: }> },
+ { path: '404', element: }> },
+ { path: '500', element: }> },
+
+ // Catch-all
+ { path: '*', element: },
+ ]);
+}
diff --git a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx
new file mode 100644
index 0000000..8ac0940
--- /dev/null
+++ b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx
@@ -0,0 +1,983 @@
+'use client';
+
+import { useRef, useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Paper from '@mui/material/Paper';
+import Table from '@mui/material/Table';
+import Avatar from '@mui/material/Avatar';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import Switch from '@mui/material/Switch';
+import Divider from '@mui/material/Divider';
+import Tooltip from '@mui/material/Tooltip';
+import TableRow from '@mui/material/TableRow';
+import Snackbar from '@mui/material/Snackbar';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TextField from '@mui/material/TextField';
+import IconButton from '@mui/material/IconButton';
+import Typography from '@mui/material/Typography';
+import LoadingButton from '@mui/lab/LoadingButton';
+import CardContent from '@mui/material/CardContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import Autocomplete from '@mui/material/Autocomplete';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import { unlinkTelegram, getTelegramStatus, getTelegramBotInfo, generateTelegramCode } from 'src/utils/telegram-api';
+import {
+ searchCities,
+ deleteAvatar,
+ updateProfile,
+ loadTelegramAvatar,
+ getProfileSettings,
+ updateProfileSettings,
+ getNotificationPreferences,
+ updateNotificationPreferences,
+} from 'src/utils/profile-api';
+
+import { CONFIG } from 'src/config-global';
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+import { useAuthContext } from 'src/auth/hooks';
+
+// ----------------------------------------------------------------------
+
+const ROLE_LABELS = {
+ mentor: 'Ментор',
+ client: 'Студент',
+ parent: 'Родитель',
+};
+
+const NOTIFICATION_TYPES = [
+ { key: 'lesson_created', label: 'Занятие создано' },
+ { key: 'lesson_cancelled', label: 'Занятие отменено' },
+ { key: 'lesson_reminder', label: 'Напоминание о занятии' },
+ { key: 'homework_assigned', label: 'ДЗ назначено' },
+ { key: 'homework_submitted', label: 'ДЗ сдано' },
+ { key: 'homework_reviewed', label: 'ДЗ проверено' },
+ { key: 'message_received', label: 'Новое сообщение' },
+ { key: 'subscription_expiring', label: 'Подписка истекает' },
+ { key: 'subscription_expired', label: 'Подписка истекла' },
+];
+
+const PARENT_EXCLUDED_TYPES = [
+ 'lesson_created', 'lesson_cancelled', 'lesson_reminder',
+ 'homework_assigned', 'homework_submitted', 'homework_reviewed',
+];
+
+const CHANNELS = [
+ { key: 'email', label: 'Email' },
+ { key: 'telegram', label: 'Telegram' },
+ { key: 'in_app', label: 'В приложении' },
+];
+
+// ----------------------------------------------------------------------
+
+function avatarSrc(src) {
+ if (!src) return '';
+ if (src.startsWith('http://') || src.startsWith('https://')) return src;
+ const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
+ return base + (src.startsWith('/') ? src : `/${src}`);
+}
+
+// ----------------------------------------------------------------------
+
+function TelegramSection({ onAvatarLoaded }) {
+ const [status, setStatus] = useState(null);
+ const [botInfo, setBotInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [codeModal, setCodeModal] = useState(false);
+ const [code, setCode] = useState('');
+ const [codeInstructions, setCodeInstructions] = useState('');
+ const [generating, setGenerating] = useState(false);
+ const [unlinking, setUnlinking] = useState(false);
+ const [loadingTgAvatar, setLoadingTgAvatar] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const pollRef = useRef(null);
+
+ const loadStatus = useCallback(async () => {
+ try {
+ const [s, b] = await Promise.all([
+ getTelegramStatus().catch(() => null),
+ getTelegramBotInfo().catch(() => null),
+ ]);
+ setStatus(s);
+ setBotInfo(b);
+ } catch {
+ // ignore
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadStatus();
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ };
+ }, [loadStatus]);
+
+ const handleGenerate = async () => {
+ setGenerating(true);
+ try {
+ const res = await generateTelegramCode();
+ setCode(res.code || '');
+ setCodeInstructions(res.instructions || '');
+ setCodeModal(true);
+ // Poll status every 5s after opening modal
+ pollRef.current = setInterval(async () => {
+ const s = await getTelegramStatus().catch(() => null);
+ if (s?.linked) {
+ setStatus(s);
+ setCodeModal(false);
+ clearInterval(pollRef.current);
+ }
+ }, 5000);
+ } catch {
+ // ignore
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleCloseModal = () => {
+ setCodeModal(false);
+ if (pollRef.current) clearInterval(pollRef.current);
+ loadStatus();
+ };
+
+ const handleUnlink = async () => {
+ setUnlinking(true);
+ try {
+ await unlinkTelegram();
+ await loadStatus();
+ } catch {
+ // ignore
+ } finally {
+ setUnlinking(false);
+ }
+ };
+
+ const handleLoadTgAvatar = async () => {
+ setLoadingTgAvatar(true);
+ try {
+ await loadTelegramAvatar();
+ if (onAvatarLoaded) onAvatarLoaded();
+ } catch {
+ // ignore
+ } finally {
+ setLoadingTgAvatar(false);
+ }
+ };
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(`/link ${code}`).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ const botLink = botInfo?.link || (botInfo?.username ? `https://t.me/${botInfo.username}` : null);
+
+ return (
+ <>
+
+ {status?.linked ? (
+
+
+
+
+ Telegram подключён
+
+ {status.telegram_username && (
+
+ @{status.telegram_username}
+
+ )}
+
+
+ }
+ >
+ Загрузить фото из Telegram
+
+ }
+ >
+ Отвязать
+
+
+
+ ) : (
+
+
+ Подключите Telegram для уведомлений
+
+ }
+ >
+ Привязать Telegram
+
+
+ )}
+
+
+ {/* Code Modal */}
+
+ >
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function NotificationMatrix({ prefs, onChange, role }) {
+ const visibleTypes = role === 'parent'
+ ? NOTIFICATION_TYPES.filter((t) => !PARENT_EXCLUDED_TYPES.includes(t.key))
+ : NOTIFICATION_TYPES;
+
+ const getTypeValue = (typeKey, channelKey) => {
+ const tp = prefs?.type_preferences;
+ if (tp && tp[typeKey] && typeof tp[typeKey][channelKey] === 'boolean') {
+ return tp[typeKey][channelKey];
+ }
+ // Fallback to channel-level setting
+ const channelMap = { email: 'email_enabled', telegram: 'telegram_enabled', in_app: 'in_app_enabled' };
+ return !!prefs?.[channelMap[channelKey]];
+ };
+
+ const handleToggle = (typeKey, channelKey) => {
+ const current = getTypeValue(typeKey, channelKey);
+ onChange({
+ type_preferences: {
+ ...prefs?.type_preferences,
+ [typeKey]: {
+ ...(prefs?.type_preferences?.[typeKey] || {}),
+ [channelKey]: !current,
+ },
+ },
+ });
+ };
+
+ return (
+
+
+
+
+ Тип уведомления
+ {CHANNELS.map((ch) => (
+
+ {ch.label}
+
+ ))}
+
+
+
+ {visibleTypes.map((type) => (
+
+
+ {type.label}
+
+ {CHANNELS.map((ch) => (
+
+ handleToggle(type.key, ch.key)}
+ disabled={!prefs?.enabled}
+ />
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function AccountPlatformView() {
+ const { user, checkUserSession } = useAuthContext();
+
+ // Profile fields
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [phone, setPhone] = useState('');
+ const [email, setEmail] = useState('');
+ const [avatarPreview, setAvatarPreview] = useState(null);
+ const [avatarHovered, setAvatarHovered] = useState(false);
+ const [uploadingAvatar, setUploadingAvatar] = useState(false);
+ const [deletingAvatar, setDeletingAvatar] = useState(false);
+
+ // Settings
+ const [settings, setSettings] = useState(null);
+ const [settingsLoading, setSettingsLoading] = useState(true);
+ const [settingsSaving, setSettingsSaving] = useState(false);
+
+ // Notification prefs
+ const [notifPrefs, setNotifPrefs] = useState(null);
+ const [notifSaving, setNotifSaving] = useState(false);
+
+ // City autocomplete
+ const [cityQuery, setCityQuery] = useState('');
+ const [cityOptions, setCityOptions] = useState([]);
+ const [citySearching, setCitySearching] = useState(false);
+ const cityDebounceRef = useRef(null);
+
+ // Auto-save debounce for settings
+ const settingsSaveRef = useRef(null);
+
+ // Snackbar
+ const [snack, setSnack] = useState({ open: false, message: '', severity: 'success' });
+
+ // ----------------------------------------------------------------------
+
+ useEffect(() => {
+ if (user) {
+ setFirstName(user.first_name || '');
+ setLastName(user.last_name || '');
+ setPhone(user.phone || '');
+ setEmail(user.email || '');
+ }
+ }, [user]);
+
+ useEffect(() => {
+ async function load() {
+ setSettingsLoading(true);
+ try {
+ const [s, n] = await Promise.all([
+ getProfileSettings().catch(() => null),
+ getNotificationPreferences().catch(() => null),
+ ]);
+ setSettings(s);
+ setNotifPrefs(n);
+ if (s?.preferences?.city) setCityQuery(s.preferences.city);
+ } finally {
+ setSettingsLoading(false);
+ }
+ }
+ load();
+ }, []);
+
+ // ----------------------------------------------------------------------
+ // City search
+
+ useEffect(() => {
+ if (cityDebounceRef.current) clearTimeout(cityDebounceRef.current);
+ if (!cityQuery || cityQuery.length < 2) {
+ setCityOptions([]);
+ return undefined;
+ }
+ cityDebounceRef.current = setTimeout(async () => {
+ setCitySearching(true);
+ const results = await searchCities(cityQuery, 20);
+ setCityOptions(results);
+ setCitySearching(false);
+ }, 400);
+ return () => {
+ if (cityDebounceRef.current) clearTimeout(cityDebounceRef.current);
+ };
+ }, [cityQuery]);
+
+ // ----------------------------------------------------------------------
+ // Avatar
+
+ const handleAvatarChange = async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setAvatarPreview(URL.createObjectURL(file));
+ setUploadingAvatar(true);
+ try {
+ await updateProfile({ avatar: file });
+ if (checkUserSession) await checkUserSession();
+ showSnack('Аватар обновлён');
+ } catch {
+ showSnack('Ошибка загрузки аватара', 'error');
+ } finally {
+ setUploadingAvatar(false);
+ }
+ };
+
+ const handleDeleteAvatar = async () => {
+ setDeletingAvatar(true);
+ try {
+ await deleteAvatar();
+ setAvatarPreview(null);
+ if (checkUserSession) await checkUserSession();
+ showSnack('Аватар удалён');
+ } catch {
+ showSnack('Ошибка удаления аватара', 'error');
+ } finally {
+ setDeletingAvatar(false);
+ }
+ };
+
+ const handleTgAvatarLoaded = async () => {
+ if (checkUserSession) await checkUserSession();
+ showSnack('Фото из Telegram загружено');
+ };
+
+ // ----------------------------------------------------------------------
+ // Profile field auto-save on blur
+
+ const handleProfileBlur = useCallback(async (field, value) => {
+ try {
+ await updateProfile({ [field]: value });
+ if (checkUserSession) await checkUserSession();
+ } catch {
+ showSnack('Ошибка сохранения', 'error');
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [checkUserSession]);
+
+ // ----------------------------------------------------------------------
+ // Settings save (debounced)
+
+ const scheduleSettingsSave = useCallback((newSettings) => {
+ setSettings(newSettings);
+ if (settingsSaveRef.current) clearTimeout(settingsSaveRef.current);
+ settingsSaveRef.current = setTimeout(async () => {
+ setSettingsSaving(true);
+ try {
+ await updateProfileSettings({
+ notifications: newSettings?.notifications,
+ preferences: newSettings?.preferences,
+ mentor_homework_ai: newSettings?.mentor_homework_ai,
+ });
+ } catch {
+ showSnack('Ошибка сохранения настроек', 'error');
+ } finally {
+ setSettingsSaving(false);
+ }
+ }, 800);
+ }, []);
+
+ // ----------------------------------------------------------------------
+ // Notification prefs save (debounced)
+
+ const notifSaveRef = useRef(null);
+
+ const scheduleNotifSave = useCallback((updated) => {
+ setNotifPrefs(updated);
+ if (notifSaveRef.current) clearTimeout(notifSaveRef.current);
+ notifSaveRef.current = setTimeout(async () => {
+ setNotifSaving(true);
+ try {
+ await updateNotificationPreferences(updated);
+ } catch {
+ showSnack('Ошибка сохранения уведомлений', 'error');
+ } finally {
+ setNotifSaving(false);
+ }
+ }, 800);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleNotifChange = (partial) => {
+ scheduleNotifSave({ ...notifPrefs, ...partial });
+ };
+
+ // ----------------------------------------------------------------------
+
+ const showSnack = (message, severity = 'success') => {
+ setSnack({ open: true, message, severity });
+ };
+
+ // ----------------------------------------------------------------------
+
+ const currentAvatar = avatarPreview || avatarSrc(user?.avatar);
+ const displayName = `${firstName} ${lastName}`.trim() || user?.email || '';
+ const roleLabel = ROLE_LABELS[user?.role] || user?.role || '';
+
+ return (
+
+
+
+
+ {/* ─── LEFT COLUMN ─── */}
+
+ {/* Avatar card */}
+
+
+
+ {/* Square avatar with hover overlay */}
+ setAvatarHovered(true)}
+ onMouseLeave={() => setAvatarHovered(false)}
+ >
+
+ {!currentAvatar && displayName[0]?.toUpperCase()}
+
+
+ {/* Hover overlay */}
+ {avatarHovered && (
+
+
+
+
+ {uploadingAvatar ? (
+
+ ) : (
+
+ )}
+
+
+
+ {currentAvatar && (
+
+
+ {deletingAvatar ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ )}
+
+
+
+ {displayName}
+ {user?.email}
+ {roleLabel && (
+
+ )}
+
+
+
+
+
+ {/* Profile fields */}
+
+
+
+ Личные данные
+
+
+
+ setFirstName(e.target.value)}
+ onBlur={(e) => handleProfileBlur('first_name', e.target.value.trim())}
+ fullWidth
+ />
+ setLastName(e.target.value)}
+ onBlur={(e) => handleProfileBlur('last_name', e.target.value.trim())}
+ fullWidth
+ />
+
+ setPhone(e.target.value)}
+ onBlur={(e) => handleProfileBlur('phone', e.target.value.trim())}
+ fullWidth
+ />
+
+
+
+
+
+ {/* City + Timezone */}
+
+
+
+ Местоположение
+
+
+ {settingsLoading ? (
+
+
+
+ ) : (
+ <>
+
+ typeof opt === 'string' ? opt : `${opt.name}${opt.region ? `, ${opt.region}` : ''}`
+ }
+ inputValue={cityQuery}
+ onInputChange={(_, val) => setCityQuery(val)}
+ onChange={(_, opt) => {
+ if (opt && typeof opt === 'object') {
+ setCityQuery(opt.name || '');
+ const newSettings = {
+ ...settings,
+ preferences: {
+ ...settings?.preferences,
+ city: opt.name || '',
+ timezone: opt.timezone || settings?.preferences?.timezone || '',
+ },
+ };
+ scheduleSettingsSave(newSettings);
+ }
+ }}
+ loading={citySearching}
+ renderInput={(params) => (
+ {
+ const newSettings = {
+ ...settings,
+ preferences: {
+ ...settings?.preferences,
+ city: cityQuery,
+ },
+ };
+ scheduleSettingsSave(newSettings);
+ }}
+ InputProps={{
+ ...params.InputProps,
+ endAdornment: (
+ <>
+ {citySearching ? : null}
+ {params.InputProps.endAdornment}
+ >
+ ),
+ }}
+ />
+ )}
+ renderOption={(props, opt) => (
+
+
+
+ {opt.name}{opt.region ? `, ${opt.region}` : ''}
+
+ {opt.timezone && (
+
+ {opt.timezone}
+
+ )}
+
+
+ )}
+ />
+
+ >
+ )}
+
+
+
+
+ {/* Telegram */}
+
+
+
+ Telegram
+
+
+
+
+
+
+ {/* ─── RIGHT COLUMN ─── */}
+
+ {/* Notification preferences */}
+
+
+
+ Уведомления
+ {notifSaving && }
+
+
+ {settingsLoading ? (
+
+
+
+ ) : (
+
+ {/* Global toggle */}
+ handleNotifChange({ enabled: !notifPrefs?.enabled })}
+ />
+ }
+ label="Включить уведомления"
+ />
+
+ {notifPrefs?.enabled && (
+ <>
+
+ {/* Channel toggles */}
+
+ handleNotifChange({ email_enabled: !notifPrefs?.email_enabled })}
+ />
+ }
+ label="Email"
+ />
+ handleNotifChange({ telegram_enabled: !notifPrefs?.telegram_enabled })}
+ />
+ }
+ label="Telegram"
+ />
+ handleNotifChange({ in_app_enabled: !notifPrefs?.in_app_enabled })}
+ />
+ }
+ label="В приложении"
+ />
+
+
+
+
+ Настройки по типу уведомлений
+
+
+ >
+ )}
+
+ )}
+
+
+
+ {/* AI homework settings (mentor only) */}
+ {user?.role === 'mentor' && settings && (
+
+
+
+ AI проверка домашних заданий
+ {settingsSaving && }
+
+
+ {
+ const newVal = !settings?.mentor_homework_ai?.ai_trust_draft;
+ scheduleSettingsSave({
+ ...settings,
+ mentor_homework_ai: {
+ ...settings?.mentor_homework_ai,
+ ai_trust_draft: newVal,
+ // Mutually exclusive
+ ai_trust_publish: newVal ? false : settings?.mentor_homework_ai?.ai_trust_publish,
+ },
+ });
+ }}
+ />
+ }
+ label="AI сохраняет как черновик (нужна ваша проверка)"
+ />
+ {
+ const newVal = !settings?.mentor_homework_ai?.ai_trust_publish;
+ scheduleSettingsSave({
+ ...settings,
+ mentor_homework_ai: {
+ ...settings?.mentor_homework_ai,
+ ai_trust_publish: newVal,
+ // Mutually exclusive
+ ai_trust_draft: newVal ? false : settings?.mentor_homework_ai?.ai_trust_draft,
+ },
+ });
+ }}
+ />
+ }
+ label="AI публикует результат автоматически"
+ />
+
+ Эти опции взаимно исключают друг друга
+
+
+
+
+ )}
+
+
+
+ setSnack((prev) => ({ ...prev, open: false }))}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
+ >
+ setSnack((prev) => ({ ...prev, open: false }))}>
+ {snack.message}
+
+
+
+ );
+}
diff --git a/front_minimal/src/sections/account-platform/view/index.js b/front_minimal/src/sections/account-platform/view/index.js
new file mode 100644
index 0000000..a272cda
--- /dev/null
+++ b/front_minimal/src/sections/account-platform/view/index.js
@@ -0,0 +1 @@
+export { AccountPlatformView } from './account-platform-view';
diff --git a/front_minimal/src/sections/analytics/view/analytics-view.jsx b/front_minimal/src/sections/analytics/view/analytics-view.jsx
new file mode 100644
index 0000000..d0bb13f
--- /dev/null
+++ b/front_minimal/src/sections/analytics/view/analytics-view.jsx
@@ -0,0 +1,387 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import { useState, useEffect, useCallback, useMemo } from 'react';
+
+import Tab from '@mui/material/Tab';
+import Box from '@mui/material/Box';
+import Tabs from '@mui/material/Tabs';
+import Stack from '@mui/material/Stack';
+import Paper from '@mui/material/Paper';
+import Typography from '@mui/material/Typography';
+import CircularProgress from '@mui/material/CircularProgress';
+import { DatePicker } from '@mui/x-date-pickers/DatePicker';
+import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
+import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
+import dayjs from 'dayjs';
+import 'dayjs/locale/ru';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+import { useAuthContext } from 'src/auth/hooks';
+import {
+ getLast30DaysRange,
+ getAnalyticsOverview,
+ getAnalyticsStudents,
+ getAnalyticsRevenue,
+ getAnalyticsGradesByDay,
+} from 'src/utils/analytics-api';
+import { getMentorIncome } from 'src/utils/dashboard-api';
+
+const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });
+
+// ----------------------------------------------------------------------
+
+const formatCurrency = (v) =>
+ new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v ?? 0);
+
+function StatCard({ label, value }) {
+ return (
+
+
+ {label}
+
+
+ {value ?? '—'}
+
+
+ );
+}
+
+function DateRangeFilter({ value, onChange, disabled }) {
+ return (
+
+
+ d && onChange({ ...value, start_date: d.format('YYYY-MM-DD') })}
+ disabled={disabled}
+ slotProps={{ textField: { size: 'small', sx: { width: 140 } } }}
+ />
+ d && onChange({ ...value, end_date: d.format('YYYY-MM-DD') })}
+ disabled={disabled}
+ slotProps={{ textField: { size: 'small', sx: { width: 140 } } }}
+ />
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function IncomeTab() {
+ const defaultRange = useMemo(() => getLast30DaysRange(), []);
+ const [range, setRange] = useState(defaultRange);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const d = await getMentorIncome('range', range.start_date, range.end_date);
+ setData(d);
+ } catch {
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [range]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const chartOptions = useMemo(() => ({
+ chart: { id: 'income-chart', toolbar: { show: false } },
+ stroke: { curve: 'smooth', width: 2 },
+ colors: ['#7C3AED'],
+ dataLabels: { enabled: false },
+ xaxis: {
+ categories: (data?.chart_data ?? []).map((d) => d.date),
+ labels: { style: { fontSize: '11px' } },
+ },
+ fill: { type: 'gradient', gradient: { opacityFrom: 0.5, opacityTo: 0.05 } },
+ tooltip: { y: { formatter: (val) => formatCurrency(val) } },
+ }), [data]);
+
+ const series = useMemo(() => [{ name: 'Доход', data: (data?.chart_data ?? []).map((d) => d.income) }], [data]);
+
+ return (
+
+
+ Доход
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ {(data?.chart_data ?? []).length > 0 ? (
+
+ ) : (
+
+ Нет данных за период
+
+ )}
+
+ {(data?.top_lessons ?? []).length > 0 && (
+
+
+ Топ занятий по доходам
+
+ {data.top_lessons.slice(0, 10).map((item, i) => (
+
+
+ {i + 1}. {item.lesson_title || item.target_name || 'Занятие'}
+
+
+ {formatCurrency(item.total_income)}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function LessonsTab() {
+ const defaultRange = useMemo(() => getLast30DaysRange(), []);
+ const [range, setRange] = useState(defaultRange);
+ const [revenue, setRevenue] = useState(null);
+ const [overview, setOverview] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [rev, ov] = await Promise.all([
+ getAnalyticsRevenue({ period: 'custom', ...range }).catch(() => null),
+ getAnalyticsOverview({ period: 'custom', ...range }).catch(() => null),
+ ]);
+ setRevenue(rev);
+ setOverview(ov);
+ } catch {
+ setRevenue(null);
+ setOverview(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [range]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const byDay = revenue?.by_day ?? [];
+
+ const chartOptions = useMemo(() => ({
+ chart: { id: 'lessons-chart', toolbar: { show: false } },
+ stroke: { curve: 'smooth', width: 2 },
+ colors: ['#7C3AED'],
+ dataLabels: { enabled: false },
+ xaxis: {
+ categories: byDay.map((d) => d.date),
+ labels: { style: { fontSize: '11px' } },
+ },
+ fill: { type: 'gradient', gradient: { opacityFrom: 0.5, opacityTo: 0.05 } },
+ tooltip: { y: { formatter: (val) => `${val} занятий` } },
+ }), [byDay]);
+
+ const series = useMemo(() => [{ name: 'Занятий', data: byDay.map((d) => d.lessons_count) }], [byDay]);
+
+ return (
+
+
+ Занятия
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ {byDay.length > 0 ? (
+
+ ) : (
+
+ Нет данных за период
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function StudentsTab() {
+ const defaultRange = useMemo(() => getLast30DaysRange(), []);
+ const [range, setRange] = useState(defaultRange);
+ const [grades, setGrades] = useState(null);
+ const [students, setStudents] = useState(null);
+ const [overview, setOverview] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const r = { period: 'custom', ...range };
+ const [gr, stu, ov] = await Promise.all([
+ getAnalyticsGradesByDay(r).catch(() => null),
+ getAnalyticsStudents(r).catch(() => null),
+ getAnalyticsOverview(r).catch(() => null),
+ ]);
+ setGrades(gr);
+ setStudents(stu);
+ setOverview(ov);
+ } catch {
+ setGrades(null);
+ setStudents(null);
+ setOverview(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [range]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const byDay = grades?.by_day ?? [];
+
+ const chartOptions = useMemo(() => ({
+ chart: { id: 'grades-chart', toolbar: { show: false } },
+ stroke: { curve: 'smooth', width: 2 },
+ colors: ['#7C3AED'],
+ dataLabels: { enabled: false },
+ xaxis: {
+ categories: byDay.map((d) => d.date),
+ labels: { style: { fontSize: '11px' } },
+ },
+ yaxis: { min: 0, max: 5 },
+ fill: { type: 'gradient', gradient: { opacityFrom: 0.5, opacityTo: 0.05 } },
+ tooltip: { y: { formatter: (val) => (val ? `Ср. оценка: ${val}` : '—') } },
+ }), [byDay]);
+
+ const series = useMemo(() => [
+ { name: 'Средняя оценка', data: byDay.map((d) => d.average_grade ?? 0) },
+ ], [byDay]);
+
+ return (
+
+
+ Успех учеников
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ {byDay.length > 0 ? (
+
+ ) : (
+
+ Нет данных за период
+
+ )}
+
+ {(students?.students ?? []).length > 0 && (
+
+
+ Топ ученики
+
+ {students.students.slice(0, 10).map((s, i) => (
+
+ {i + 1}. {s.name}
+
+ {s.lessons_completed} занятий · ср. {s.average_grade}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function AnalyticsView() {
+ const { user } = useAuthContext();
+ const [tab, setTab] = useState(0);
+
+ if (user?.role !== 'mentor') {
+ return (
+
+
+ Аналитика доступна только менторам.
+
+
+ );
+ }
+
+ return (
+
+
+ Аналитика
+
+
+
+ setTab(v)} sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
+
+
+
+
+
+
+ {tab === 0 && }
+ {tab === 1 && }
+ {tab === 2 && }
+
+
+
+ );
+}
diff --git a/front_minimal/src/sections/analytics/view/index.js b/front_minimal/src/sections/analytics/view/index.js
new file mode 100644
index 0000000..dba59a2
--- /dev/null
+++ b/front_minimal/src/sections/analytics/view/index.js
@@ -0,0 +1 @@
+export * from './analytics-view';
diff --git a/front_minimal/src/sections/auth/jwt/index.js b/front_minimal/src/sections/auth/jwt/index.js
index 0e2428a..e078f8a 100644
--- a/front_minimal/src/sections/auth/jwt/index.js
+++ b/front_minimal/src/sections/auth/jwt/index.js
@@ -1,3 +1,5 @@
export * from './jwt-sign-in-view';
-
export * from './jwt-sign-up-view';
+export * from './jwt-forgot-password-view';
+export * from './jwt-reset-password-view';
+export * from './jwt-verify-email-view';
diff --git a/front_minimal/src/sections/auth/jwt/jwt-forgot-password-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-forgot-password-view.jsx
new file mode 100644
index 0000000..da41821
--- /dev/null
+++ b/front_minimal/src/sections/auth/jwt/jwt-forgot-password-view.jsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { z as zod } from 'zod';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import Link from '@mui/material/Link';
+import Alert from '@mui/material/Alert';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import LoadingButton from '@mui/lab/LoadingButton';
+
+import { paths } from 'src/routes/paths';
+import { RouterLink } from 'src/routes/components';
+
+import { Form, Field } from 'src/components/hook-form';
+
+import { requestPasswordReset } from 'src/auth/context/jwt';
+
+// ----------------------------------------------------------------------
+
+const ForgotPasswordSchema = zod.object({
+ email: zod
+ .string()
+ .min(1, { message: 'Email is required!' })
+ .email({ message: 'Email must be a valid email address!' }),
+});
+
+// ----------------------------------------------------------------------
+
+export function JwtForgotPasswordView() {
+ const [errorMsg, setErrorMsg] = useState('');
+ const [successMsg, setSuccessMsg] = useState('');
+
+ const methods = useForm({
+ resolver: zodResolver(ForgotPasswordSchema),
+ defaultValues: { email: '' },
+ });
+
+ const {
+ handleSubmit,
+ formState: { isSubmitting },
+ } = methods;
+
+ const onSubmit = handleSubmit(async (data) => {
+ try {
+ await requestPasswordReset({ email: data.email });
+ setSuccessMsg('Password reset instructions have been sent to your email.');
+ setErrorMsg('');
+ } catch (error) {
+ console.error(error);
+ const msg = error?.response?.data?.message || error?.response?.data?.detail || 'Error sending request. Please check your email.';
+ setErrorMsg(msg);
+ }
+ });
+
+ return (
+ <>
+
+ Forgot your password?
+
+
+ Enter your email address and we will send you a link to reset your password.
+
+
+
+ {!!successMsg && (
+
+ {successMsg}
+
+ )}
+
+ {!!errorMsg && (
+
+ {errorMsg}
+
+ )}
+
+ {!successMsg && (
+
+ )}
+
+
+ Back to sign in
+
+ >
+ );
+}
diff --git a/front_minimal/src/sections/auth/jwt/jwt-reset-password-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-reset-password-view.jsx
new file mode 100644
index 0000000..81191ce
--- /dev/null
+++ b/front_minimal/src/sections/auth/jwt/jwt-reset-password-view.jsx
@@ -0,0 +1,204 @@
+'use client';
+
+import { z as zod } from 'zod';
+import { useState, Suspense } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useSearchParams } from 'next/navigation';
+
+import Link from '@mui/material/Link';
+import Alert from '@mui/material/Alert';
+import Stack from '@mui/material/Stack';
+import IconButton from '@mui/material/IconButton';
+import Typography from '@mui/material/Typography';
+import LoadingButton from '@mui/lab/LoadingButton';
+import InputAdornment from '@mui/material/InputAdornment';
+
+import { paths } from 'src/routes/paths';
+import { useRouter } from 'src/routes/hooks';
+import { RouterLink } from 'src/routes/components';
+
+import { useBoolean } from 'src/hooks/use-boolean';
+
+import { Iconify } from 'src/components/iconify';
+import { Form, Field } from 'src/components/hook-form';
+
+import { confirmPasswordReset } from 'src/auth/context/jwt';
+
+// ----------------------------------------------------------------------
+
+const ResetPasswordSchema = zod
+ .object({
+ newPassword: zod
+ .string()
+ .min(1, { message: 'Password is required!' })
+ .min(6, { message: 'Password must be at least 6 characters!' }),
+ newPasswordConfirm: zod.string().min(1, { message: 'Please confirm your password!' }),
+ })
+ .refine((data) => data.newPassword === data.newPasswordConfirm, {
+ message: 'Passwords do not match!',
+ path: ['newPasswordConfirm'],
+ });
+
+// ----------------------------------------------------------------------
+
+function ResetPasswordContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const token = searchParams.get('token');
+
+ const [errorMsg, setErrorMsg] = useState('');
+ const [successMsg, setSuccessMsg] = useState('');
+
+ const newPassword = useBoolean();
+ const newPasswordConfirm = useBoolean();
+
+ const methods = useForm({
+ resolver: zodResolver(ResetPasswordSchema),
+ defaultValues: { newPassword: '', newPasswordConfirm: '' },
+ });
+
+ const {
+ handleSubmit,
+ formState: { isSubmitting },
+ } = methods;
+
+ const onSubmit = handleSubmit(async (data) => {
+ if (!token) {
+ setErrorMsg('Reset link is missing. Please request a new password reset.');
+ return;
+ }
+ try {
+ await confirmPasswordReset({
+ token,
+ newPassword: data.newPassword,
+ newPasswordConfirm: data.newPasswordConfirm,
+ });
+ setSuccessMsg('Password changed successfully. You can now sign in with your new password.');
+ setErrorMsg('');
+ } catch (error) {
+ console.error(error);
+ const msg = error?.response?.data?.message || error?.response?.data?.detail || 'Failed to reset password. The link may have expired — please request a new one.';
+ setErrorMsg(msg);
+ }
+ });
+
+ if (!token) {
+ return (
+ <>
+
+ Reset password
+
+
+ Reset link is missing. Please follow the link from your email or request a new one.
+
+
+ Request new reset link
+
+ >
+ );
+ }
+
+ if (successMsg) {
+ return (
+ <>
+
+ Reset password
+
+
+ {successMsg}
+
+
+ Sign in
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ Set new password
+
+ Enter your new password below.
+
+
+
+ {!!errorMsg && (
+
+ {errorMsg}
+
+ )}
+
+
+
+
+ Back to sign in
+
+ >
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function JwtResetPasswordView() {
+ return (
+ Loading...}>
+
+
+ );
+}
diff --git a/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx
index cc3b80a..5b1b840 100644
--- a/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx
+++ b/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx
@@ -50,8 +50,8 @@ export function JwtSignInView() {
const password = useBoolean();
const defaultValues = {
- email: 'demo@minimals.cc',
- password: '@demo1',
+ email: 'mentor@demo.uchill.online',
+ password: 'demo123456',
};
const methods = useForm({
@@ -99,7 +99,7 @@ export function JwtSignInView() {
data.password === data.passwordConfirm, {
+ message: 'Passwords do not match!',
+ path: ['passwordConfirm'],
+ });
// ----------------------------------------------------------------------
export function JwtSignUpView() {
const { checkUserSession } = useAuthContext();
-
const router = useRouter();
const password = useBoolean();
+ const passwordConfirm = useBoolean();
const [errorMsg, setErrorMsg] = useState('');
+ const [successMsg, setSuccessMsg] = useState('');
+ const [consent, setConsent] = useState(false);
const defaultValues = {
- firstName: 'Hello',
- lastName: 'Friend',
- email: 'hello@gmail.com',
- password: '@demo1',
+ firstName: '',
+ lastName: '',
+ email: '',
+ role: 'client',
+ city: '',
+ password: '',
+ passwordConfirm: '',
};
const methods = useForm({
@@ -69,19 +85,35 @@ export function JwtSignUpView() {
} = methods;
const onSubmit = handleSubmit(async (data) => {
+ if (!consent) {
+ setErrorMsg('Please agree to the Terms of Service and Privacy Policy.');
+ return;
+ }
+
try {
- await signUp({
+ const result = await signUp({
email: data.email,
password: data.password,
+ passwordConfirm: data.passwordConfirm,
firstName: data.firstName,
lastName: data.lastName,
+ role: data.role,
+ city: data.city,
});
- await checkUserSession?.();
+ if (result?.requiresVerification) {
+ setSuccessMsg(
+ `A confirmation email has been sent to ${data.email}. Please follow the link to verify your account.`
+ );
+ return;
+ }
+
+ await checkUserSession?.();
router.refresh();
} catch (error) {
console.error(error);
- setErrorMsg(error instanceof Error ? error.message : error);
+ const msg = error?.response?.data?.message || error?.response?.data?.detail || (error instanceof Error ? error.message : 'Registration error. Please check your data.');
+ setErrorMsg(msg);
}
});
@@ -101,6 +133,20 @@ export function JwtSignUpView() {
);
+ if (successMsg) {
+ return (
+ <>
+ {renderHead}
+ {successMsg}
+
+
+ Back to sign in
+
+
+ >
+ );
+ }
+
const renderForm = (
@@ -110,6 +156,14 @@ export function JwtSignUpView() {
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+ setConsent(e.target.checked)} />}
+ label={
+
+ I agree to the{' '}
+
+ Terms of service
+ {' '}
+ and{' '}
+
+ Privacy policy
+
+
+ }
+ />
+
Create account
);
- const renderTerms = (
-
- {'By signing up, I agree to '}
-
- Terms of service
-
- {' and '}
-
- Privacy policy
-
- .
-
- );
-
return (
<>
{renderHead}
@@ -176,8 +241,6 @@ export function JwtSignUpView() {
-
- {renderTerms}
>
);
}
diff --git a/front_minimal/src/sections/auth/jwt/jwt-verify-email-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-verify-email-view.jsx
new file mode 100644
index 0000000..ac326c4
--- /dev/null
+++ b/front_minimal/src/sections/auth/jwt/jwt-verify-email-view.jsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { useState, useEffect, Suspense } from 'react';
+import { useSearchParams } from 'next/navigation';
+
+import Link from '@mui/material/Link';
+import Alert from '@mui/material/Alert';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+import { RouterLink } from 'src/routes/components';
+
+import { verifyEmail } from 'src/auth/context/jwt';
+
+// ----------------------------------------------------------------------
+
+function VerifyEmailContent() {
+ const searchParams = useSearchParams();
+ const token = searchParams.get('token');
+
+ const [status, setStatus] = useState('loading'); // 'loading' | 'success' | 'error'
+ const [message, setMessage] = useState('');
+
+ useEffect(() => {
+ if (!token) {
+ setStatus('error');
+ setMessage('Verification link is missing. Please check your email or request a new one.');
+ return;
+ }
+
+ let cancelled = false;
+
+ verifyEmail({ token })
+ .then((res) => {
+ if (cancelled) return;
+ if (res?.success) {
+ setStatus('success');
+ setMessage('Email successfully verified. You can now sign in.');
+ } else {
+ setStatus('error');
+ setMessage(res?.message || 'Failed to verify email.');
+ }
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setStatus('error');
+ const msg = err?.response?.data?.message || err?.response?.data?.detail || 'Invalid or expired link. Please request a new verification email.';
+ setMessage(msg);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [token]);
+
+ return (
+ <>
+
+ Email verification
+
+
+ {status === 'loading' && (
+
+
+
+ Verifying your email...
+
+
+ )}
+
+ {status === 'success' && (
+ <>
+
+ {message}
+
+
+ Sign in to your account
+
+ >
+ )}
+
+ {status === 'error' && (
+ <>
+
+ {message}
+
+
+
+ Back to sign in
+
+
+ Create a new account
+
+
+ >
+ )}
+ >
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function JwtVerifyEmailView() {
+ return (
+ Loading...}>
+
+
+ );
+}
diff --git a/front_minimal/src/sections/board/view/board-view.jsx b/front_minimal/src/sections/board/view/board-view.jsx
new file mode 100644
index 0000000..a082b0d
--- /dev/null
+++ b/front_minimal/src/sections/board/view/board-view.jsx
@@ -0,0 +1,392 @@
+'use client';
+
+import { useRef, useEffect, useState, useCallback } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Grid from '@mui/material/Unstable_Grid2';
+import Stack from '@mui/material/Stack';
+import Button from '@mui/material/Button';
+import Avatar from '@mui/material/Avatar';
+import Tooltip from '@mui/material/Tooltip';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import CardContent from '@mui/material/CardContent';
+import CardActionArea from '@mui/material/CardActionArea';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { CONFIG } from 'src/config-global';
+import { Iconify } from 'src/components/iconify';
+import { DashboardContent } from 'src/layouts/dashboard';
+import { useAuthContext } from 'src/auth/hooks';
+import { getMyBoards, getSharedBoards, getOrCreateMentorStudentBoard } from 'src/utils/board-api';
+import { getMentorStudents } from 'src/utils/dashboard-api';
+
+// ----------------------------------------------------------------------
+
+function buildExcalidrawSrc(boardId, user) {
+ const token =
+ typeof window !== 'undefined'
+ ? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
+ : '';
+
+ const serverUrl = CONFIG.site.serverUrl || '';
+ const apiUrl = serverUrl.replace(/\/api\/?$/, '') || '';
+ const isMentor = user?.role === 'mentor';
+
+ const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
+ const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
+ const excalidrawPort = process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001';
+ const yjsPort = process.env.NEXT_PUBLIC_YJS_PORT || '1236';
+
+ if (excalidrawUrl && excalidrawUrl.startsWith('http')) {
+ const url = new URL(`${excalidrawUrl}/`);
+ url.searchParams.set('boardId', boardId);
+ url.searchParams.set('apiUrl', apiUrl);
+ url.searchParams.set('yjsPort', yjsPort);
+ if (token) url.searchParams.set('token', token);
+ if (isMentor) url.searchParams.set('isMentor', '1');
+ return url.toString();
+ }
+
+ const origin = excalidrawPath
+ ? (typeof window !== 'undefined' ? window.location.origin : '')
+ : `${typeof window !== 'undefined' ? window.location.protocol : 'https:'}//${typeof window !== 'undefined' ? window.location.hostname : ''}:${excalidrawPort}`;
+ const pathname = excalidrawPath ? `/${excalidrawPath.replace(/^\//, '')}/` : '/';
+ const params = new URLSearchParams({ boardId, apiUrl });
+ params.set('yjsPort', yjsPort);
+ if (token) params.set('token', token);
+ if (isMentor) params.set('isMentor', '1');
+ return `${origin}${pathname}?${params.toString()}`;
+}
+
+// ----------------------------------------------------------------------
+
+function WhiteboardIframe({ boardId, user }) {
+ const containerRef = useRef(null);
+ const iframeRef = useRef(null);
+ const createdRef = useRef(false);
+ const username = user ? `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email : 'Пользователь';
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || !containerRef.current || !boardId) return undefined;
+ if (createdRef.current) return undefined;
+
+ const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim();
+ const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
+ const iframeSrc = buildExcalidrawSrc(boardId, user);
+
+ const sendUsername = (iframe) => {
+ if (!iframe.contentWindow) return;
+ try {
+ const targetOrigin = excalidrawUrl.startsWith('http')
+ ? new URL(excalidrawUrl).origin
+ : window.location.origin;
+ iframe.contentWindow.postMessage({ type: 'excalidraw-username', username }, targetOrigin);
+ } catch (_e) {
+ // ignore cross-origin errors
+ }
+ };
+
+ const iframe = document.createElement('iframe');
+ iframe.src = iframeSrc;
+ iframe.style.cssText = 'width:100%;height:100%;border:none;display:block';
+ iframe.title = 'Интерактивная доска';
+ iframe.setAttribute('allow', 'camera; microphone; fullscreen');
+ iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
+ iframe.onload = () => { sendUsername(iframe); setTimeout(() => sendUsername(iframe), 500); };
+
+ containerRef.current.appendChild(iframe);
+ iframeRef.current = iframe;
+ createdRef.current = true;
+ setTimeout(() => sendUsername(iframe), 300);
+
+ return () => {
+ createdRef.current = false;
+ iframeRef.current = null;
+ iframe.remove();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [boardId]);
+
+ return ;
+}
+
+// ----------------------------------------------------------------------
+
+function getUserName(u) {
+ if (!u) return '—';
+ if (typeof u === 'object') return [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email || '—';
+ return String(u);
+}
+
+function getUserInitials(u) {
+ if (!u || typeof u !== 'object') return '?';
+ return [u.first_name?.[0], u.last_name?.[0]].filter(Boolean).join('').toUpperCase() || (u.email?.[0] || '?').toUpperCase();
+}
+
+function BoardCard({ board, currentUser, onClick }) {
+ const isMentor = currentUser?.role === 'mentor';
+ const otherPerson = isMentor ? board.student : board.mentor;
+ const otherName = getUserName(otherPerson);
+ const otherInitials = getUserInitials(otherPerson);
+ const lastEdited = board.last_edited_at
+ ? new Date(board.last_edited_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
+ : null;
+
+ return (
+
+
+ {/* Preview area */}
+
+
+ {board.elements_count > 0 && (
+
+
+ {board.elements_count} элем.
+
+
+ )}
+
+
+
+
+ {board.title || 'Без названия'}
+
+
+
+
+ {otherInitials}
+
+
+ {isMentor ? 'Ученик: ' : 'Ментор: '}{otherName}
+
+
+
+ {lastEdited && (
+
+ Изменено {lastEdited}
+
+ )}
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function BoardListView({ onOpen }) {
+ const { user } = useAuthContext();
+ const [myBoards, setMyBoards] = useState([]);
+ const [sharedBoards, setSharedBoards] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
+ const [students, setStudents] = useState([]);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [mine, shared] = await Promise.all([
+ getMyBoards().catch(() => []),
+ getSharedBoards().catch(() => []),
+ ]);
+ setMyBoards(Array.isArray(mine) ? mine : []);
+ setSharedBoards(Array.isArray(shared) ? shared : []);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ // Ментор может создать доску с учеником
+ useEffect(() => {
+ if (user?.role !== 'mentor') return;
+ getMentorStudents()
+ .then((res) => {
+ const list = Array.isArray(res) ? res : res?.results || [];
+ setStudents(list);
+ })
+ .catch(() => {});
+ }, [user?.role]);
+
+ const handleCreateWithStudent = async (student) => {
+ setCreating(true);
+ try {
+ const board = await getOrCreateMentorStudentBoard(user.id, student.id ?? student.user?.id ?? student);
+ await load();
+ onOpen(board.board_id);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const excalidrawAvailable = !!(
+ process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
+ process.env.NEXT_PUBLIC_EXCALIDRAW_PATH ||
+ process.env.NEXT_PUBLIC_EXCALIDRAW_PORT
+ );
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ const allBoards = [...myBoards, ...sharedBoards];
+ const uniqueBoards = allBoards.filter((b, i, arr) => arr.findIndex((x) => x.board_id === b.board_id) === i);
+
+ return (
+
+ {!excalidrawAvailable && (
+
+
+ Excalidraw не настроен. Укажите NEXT_PUBLIC_EXCALIDRAW_URL или NEXT_PUBLIC_EXCALIDRAW_PATH в .env
+
+
+ )}
+
+
+ Доски
+
+ {user?.role === 'mentor' && students.length > 0 && (
+
+ {students.slice(0, 6).map((s) => {
+ const name = getUserName(s.user || s);
+ const initials = getUserInitials(s.user || s);
+ return (
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {uniqueBoards.length === 0 ? (
+
+
+ Досок пока нет
+ {user?.role === 'mentor' && (
+
+ Нажмите на имя ученика выше, чтобы открыть совместную доску
+
+ )}
+
+ ) : (
+
+ {uniqueBoards.map((board) => (
+
+ onOpen(board.board_id)} />
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function BoardView() {
+ const { user } = useAuthContext();
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ const boardId = searchParams.get('id') || searchParams.get('board_id');
+
+ const handleOpen = (id) => {
+ router.push(`/dashboard/board?id=${id}`);
+ };
+
+ const handleBack = () => {
+ router.push('/dashboard/board');
+ };
+
+ if (boardId) {
+ return (
+
+
+
+
+
+ Интерактивная доска
+ #{boardId}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/front_minimal/src/sections/board/view/index.js b/front_minimal/src/sections/board/view/index.js
new file mode 100644
index 0000000..b067e1a
--- /dev/null
+++ b/front_minimal/src/sections/board/view/index.js
@@ -0,0 +1 @@
+export { BoardView } from './board-view';
diff --git a/front_minimal/src/sections/calendar/calendar-form.jsx b/front_minimal/src/sections/calendar/calendar-form.jsx
index 2f0c074..78913e9 100644
--- a/front_minimal/src/sections/calendar/calendar-form.jsx
+++ b/front_minimal/src/sections/calendar/calendar-form.jsx
@@ -1,39 +1,45 @@
+import 'dayjs/locale/ru';
+import dayjs from 'dayjs';
import { z as zod } from 'zod';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
-import { useMemo, useCallback } from 'react';
-import dayjs from 'dayjs';
-import 'dayjs/locale/ru';
+import { useMemo, useState, useCallback } from 'react';
// ----------------------------------------------------------------------
-dayjs.locale('ru');
-
-// ----------------------------------------------------------------------
+import { useRouter } from 'next/navigation';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import Avatar from '@mui/material/Avatar';
+import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip';
import MenuItem from '@mui/material/MenuItem';
-import Switch from '@mui/material/Switch';
-import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import LoadingButton from '@mui/lab/LoadingButton';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import InputAdornment from '@mui/material/InputAdornment';
+import FormControlLabel from '@mui/material/FormControlLabel';
+
+import { paths } from 'src/routes/paths';
+
+import { createLiveKitRoom } from 'src/utils/livekit-api';
import { useGetStudents, useGetSubjects } from 'src/actions/calendar';
-import { fIsAfter } from 'src/utils/format-time';
+
import { Iconify } from 'src/components/iconify';
import { Form, Field } from 'src/components/hook-form';
// ----------------------------------------------------------------------
+dayjs.locale('ru');
+
+// ----------------------------------------------------------------------
+
const DURATION_OPTIONS = [
{ value: 30, label: '30 минут' },
{ value: 45, label: '45 минут' },
@@ -51,8 +57,10 @@ export function CalendarForm({
onUpdateEvent,
onDeleteEvent,
}) {
+ const router = useRouter();
const { students } = useGetStudents();
const { subjects } = useGetSubjects();
+ const [joiningVideo, setJoiningVideo] = useState(false);
const EventSchema = zod.object({
client: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'),
@@ -112,12 +120,12 @@ export function CalendarForm({
const displayTitle = `${subjectName} ${mentorFullName} - ${studentFullName}${lessonNumber}`.trim();
const payload = {
- title: displayTitle, // Добавляем обязательное поле title
+ title: displayTitle,
client: data.client,
subject: data.subject,
description: data.description,
start_time: startTime.toISOString(),
- end_time: endTime.toISOString(),
+ duration: data.duration,
price: data.price,
is_recurring: data.is_recurring,
};
@@ -134,6 +142,21 @@ export function CalendarForm({
}
});
+ const onJoinVideo = useCallback(async () => {
+ if (!currentEvent?.id) return;
+ setJoiningVideo(true);
+ try {
+ const room = await createLiveKitRoom(currentEvent.id);
+ const token = room.access_token || room.token;
+ router.push(`${paths.videoCall}?token=${encodeURIComponent(token)}&lesson_id=${currentEvent.id}`);
+ onClose();
+ } catch (e) {
+ console.error('LiveKit join error', e);
+ } finally {
+ setJoiningVideo(false);
+ }
+ }, [currentEvent?.id, router, onClose]);
+
const onDelete = useCallback(async () => {
try {
await onDeleteEvent(`${currentEvent?.id}`);
@@ -270,6 +293,18 @@ export function CalendarForm({
+ {currentEvent?.id && currentEvent?.extendedProps?.status !== 'completed' && (
+ }
+ >
+ Войти
+
+ )}
+
diff --git a/front_minimal/src/sections/calendar/calendar-toolbar.jsx b/front_minimal/src/sections/calendar/calendar-toolbar.jsx
index 44e3f01..306889d 100644
--- a/front_minimal/src/sections/calendar/calendar-toolbar.jsx
+++ b/front_minimal/src/sections/calendar/calendar-toolbar.jsx
@@ -74,12 +74,6 @@ export function CalendarToolbar({
-
-
-
-
-
-
{loading && (
diff --git a/front_minimal/src/sections/calendar/view/calendar-view.jsx b/front_minimal/src/sections/calendar/view/calendar-view.jsx
index e14f0c2..e881785 100644
--- a/front_minimal/src/sections/calendar/view/calendar-view.jsx
+++ b/front_minimal/src/sections/calendar/view/calendar-view.jsx
@@ -17,6 +17,7 @@ import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import { paths } from 'src/routes/paths';
+import { useAuthContext } from 'src/auth/hooks';
import { useBoolean } from 'src/hooks/use-boolean';
import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar';
@@ -27,86 +28,49 @@ import { useSettingsContext } from 'src/components/settings';
import { CalendarForm } from '../calendar-form';
import { StyledCalendar } from '../styles';
import { CalendarToolbar } from '../calendar-toolbar';
-import { CalendarFilters } from '../calendar-filters';
import { useCalendar } from '../hooks/use-calendar';
// ----------------------------------------------------------------------
export function CalendarView() {
const settings = useSettingsContext();
+ const { user } = useAuthContext();
+ const isMentor = user?.role === 'mentor';
- const { events, eventsLoading } = useGetEvents();
+ const { calendarRef, view, date, onDatePrev, onDateNext, onDateToday, onChangeView,
+ onSelectRange, onClickEvent, onResizeEvent, onDropEvent, onInitialView,
+ openForm, onOpenForm, onCloseForm, selectEventId, selectedRange } = useCalendar();
- const {
- calendarRef,
- view,
- date,
- onDatePrev,
- onDateNext,
- onDateToday,
- onChangeView,
- onSelectRange,
- onClickEvent,
- onResizeEvent,
- onDropEvent,
- onInitialView,
- openForm,
- onOpenForm,
- onCloseForm,
- selectEventId,
- selectedRange,
- onClickEventInFilters,
- } = useCalendar();
-
- const openFilters = useBoolean();
-
- const [filters, setFilters] = useState({
- colors: [],
- startDate: null,
- endDate: null,
- });
-
- const dateError = filters.startDate && filters.endDate ? filters.startDate > filters.endDate : false;
+ const { events, eventsLoading } = useGetEvents(date);
useEffect(() => {
onInitialView();
}, [onInitialView]);
- const handleFilters = useCallback((name, value) => {
- setFilters((prevState) => ({
- ...prevState,
- [name]: value,
- }));
- }, []);
+ const handleCreateEvent = useCallback(
+ (eventData) => createEvent(eventData, date),
+ [date]
+ );
- const handleResetFilters = useCallback(() => {
- setFilters({
- colors: [],
- startDate: null,
- endDate: null,
- });
- }, []);
+ const handleUpdateEvent = useCallback(
+ (eventData) => updateEvent(eventData, date),
+ [date]
+ );
- const dataPrepared = events.filter((event) => {
- const { colors, startDate, endDate } = filters;
-
- if (colors.length && !colors.includes(event.color)) {
- return false;
- }
-
- if (startDate && endDate) {
- const eventStart = new Date(event.start);
- const eventEnd = new Date(event.end);
- return eventStart >= startDate && eventEnd <= endDate;
- }
-
- return true;
- });
+ const handleDeleteEvent = useCallback(
+ (eventId, deleteAllFuture) => deleteEvent(eventId, deleteAllFuture, date),
+ [date]
+ );
return (
<>
-
+
Расписание
- }
- onClick={() => onOpenForm()}
- >
- Новое занятие
-
+ {isMentor && (
+ }
+ onClick={() => onOpenForm()}
+ >
+ Новое занятие
+
+ )}
@@ -136,16 +102,15 @@ export function CalendarView() {
onPrevDate={onDatePrev}
onToday={onDateToday}
onChangeView={onChangeView}
- onOpenFilters={openFilters.onTrue}
/>
- event.id === selectEventId)}
- range={selectedRange}
- open={openForm}
- onClose={onCloseForm}
- onCreateEvent={createEvent}
- onUpdateEvent={updateEvent}
- onDeleteEvent={deleteEvent}
- />
-
-
+ {isMentor && (
+ event.id === selectEventId)}
+ range={selectedRange}
+ open={openForm}
+ onClose={onCloseForm}
+ onCreateEvent={handleCreateEvent}
+ onUpdateEvent={handleUpdateEvent}
+ onDeleteEvent={handleDeleteEvent}
+ />
+ )}
>
);
}
diff --git a/front_minimal/src/sections/chat/view/chat-platform-view.jsx b/front_minimal/src/sections/chat/view/chat-platform-view.jsx
new file mode 100644
index 0000000..47a4666
--- /dev/null
+++ b/front_minimal/src/sections/chat/view/chat-platform-view.jsx
@@ -0,0 +1,737 @@
+'use client';
+
+import { useRef, useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import List from '@mui/material/List';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Badge from '@mui/material/Badge';
+import Avatar from '@mui/material/Avatar';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import Divider from '@mui/material/Divider';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import DialogTitle from '@mui/material/DialogTitle';
+import ListItemText from '@mui/material/ListItemText';
+import DialogContent from '@mui/material/DialogContent';
+import DialogActions from '@mui/material/DialogActions';
+import InputAdornment from '@mui/material/InputAdornment';
+import ListItemButton from '@mui/material/ListItemButton';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import { useChatWebSocket } from 'src/hooks/use-chat-websocket';
+
+import {
+ createChat,
+ getMessages,
+ searchUsers,
+ sendMessage,
+ getConversations,
+ markMessagesAsRead,
+ getChatMessagesByUuid,
+} from 'src/utils/chat-api';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+import { useAuthContext } from 'src/auth/hooks';
+
+// ----------------------------------------------------------------------
+
+function formatTime(ts) {
+ try {
+ if (!ts) return '';
+ const d = new Date(ts);
+ if (Number.isNaN(d.getTime())) return '';
+ return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+ } catch {
+ return '';
+ }
+}
+
+function dateKey(ts) {
+ if (!ts) return '';
+ const d = new Date(ts);
+ if (Number.isNaN(d.getTime())) return '';
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
+
+function formatDayHeader(ts) {
+ if (!ts) return '';
+ const d = new Date(ts);
+ if (Number.isNaN(d.getTime())) return '';
+ const now = new Date();
+ const todayKey = dateKey(now.toISOString());
+ const yKey = dateKey(new Date(now.getTime() - 86400000).toISOString());
+ const k = dateKey(ts);
+ if (k === todayKey) return 'Сегодня';
+ if (k === yKey) return 'Вчера';
+ return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
+}
+
+function getInitials(name) {
+ return (name || 'Ч')
+ .trim()
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((p) => p[0])
+ .join('')
+ .toUpperCase();
+}
+
+function stripHtml(s) {
+ if (typeof s !== 'string') return '';
+ return s.replace(/<[^>]*>/g, '').trim();
+}
+
+// ----------------------------------------------------------------------
+
+function NewChatDialog({ open, onClose, onCreated }) {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [searching, setSearching] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSearch = useCallback(async (q) => {
+ if (!q.trim()) {
+ setResults([]);
+ return;
+ }
+ try {
+ setSearching(true);
+ const users = await searchUsers(q.trim());
+ setResults(users);
+ } catch {
+ setResults([]);
+ } finally {
+ setSearching(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const timer = setTimeout(() => handleSearch(query), 400);
+ return () => clearTimeout(timer);
+ }, [query, handleSearch]);
+
+ const handleCreate = async (userId) => {
+ try {
+ setCreating(true);
+ setError(null);
+ const chat = await createChat(userId);
+ onCreated(chat);
+ onClose();
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка создания чата');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleClose = () => {
+ setQuery('');
+ setResults([]);
+ setError(null);
+ onClose();
+ };
+
+ return (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function ChatList({ chats, selectedUuid, onSelect, onNew, loading }) {
+ const [q, setQ] = useState('');
+
+ const filtered = chats.filter((c) => {
+ if (!q.trim()) return true;
+ const qq = q.toLowerCase();
+ return (
+ (c.participant_name || '').toLowerCase().includes(qq) ||
+ (c.last_message || '').toLowerCase().includes(qq)
+ );
+ });
+
+ return (
+
+
+ setQ(e.target.value)}
+ placeholder="Поиск"
+ size="small"
+ fullWidth
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
+ {q ? 'Не найдено' : 'Нет чатов'}
+
+ ) : (
+
+ {filtered.map((chat) => {
+ const selected = !!selectedUuid && chat.uuid === selectedUuid;
+ return (
+ onSelect(chat)}
+ sx={{ py: 1.25, px: 1.5 }}
+ >
+
+ {getInitials(chat.participant_name)}
+
+
+
+ {chat.participant_name || 'Чат'}
+
+ {!!chat.unread_count && (
+
+ )}
+
+ }
+ secondary={
+
+ {stripHtml(chat.last_message || '')}
+
+ }
+ primaryTypographyProps={{ component: 'div' }}
+ secondaryTypographyProps={{ component: 'div' }}
+ />
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function ChatWindow({ chat, currentUserId, onBack }) {
+ const [messages, setMessages] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(false);
+ const [text, setText] = useState('');
+ const [sending, setSending] = useState(false);
+ const listRef = useRef(null);
+ const markedRef = useRef(new Set());
+ const lastSentRef = useRef(null);
+ const lastWheelUpRef = useRef(0);
+
+ const chatUuid = chat?.uuid || null;
+
+ useChatWebSocket({
+ chatUuid,
+ enabled: !!chatUuid,
+ onMessage: (m) => {
+ const chatId = chat?.id != null ? Number(chat.id) : null;
+ const msgChatId = m.chat != null ? Number(m.chat) : null;
+ if (chatId == null || msgChatId !== chatId) return;
+ const mid = m.id;
+ const muuid = m.uuid;
+ const sent = lastSentRef.current;
+ if (
+ sent &&
+ (String(mid) === String(sent.id) ||
+ (muuid != null && sent.uuid != null && String(muuid) === String(sent.uuid)))
+ ) {
+ lastSentRef.current = null;
+ return;
+ }
+ setMessages((prev) => {
+ const isDuplicate = prev.some((x) => {
+ const sameId = mid != null && x.id != null && String(x.id) === String(mid);
+ const sameUuid = muuid != null && x.uuid != null && String(x.uuid) === String(muuid);
+ return sameId || sameUuid;
+ });
+ if (isDuplicate) return prev;
+ return [...prev, m];
+ });
+ },
+ });
+
+ useEffect(() => {
+ if (!chat) return undefined;
+ setLoading(true);
+ setPage(1);
+ setHasMore(false);
+ markedRef.current = new Set();
+ lastSentRef.current = null;
+
+ const fetchMessages = async () => {
+ try {
+ const PAGE_SIZE = 30;
+ const resp = chatUuid
+ ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: PAGE_SIZE })
+ : await getMessages(chat.id, { page: 1, page_size: PAGE_SIZE });
+ const sorted = (resp.results || []).slice().sort((a, b) => {
+ const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
+ const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
+ return ta - tb;
+ });
+ setMessages(sorted);
+ setHasMore(!!resp.next || (resp.count ?? 0) > sorted.length);
+ requestAnimationFrame(() => {
+ const el = listRef.current;
+ if (el) el.scrollTop = el.scrollHeight;
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchMessages();
+ return undefined;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chat?.id, chatUuid]);
+
+ useEffect(() => {
+ if (!chatUuid || !listRef.current || messages.length === 0) return undefined;
+ const container = listRef.current;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const toMark = [];
+ entries.forEach((e) => {
+ if (!e.isIntersecting) return;
+ const uuid = e.target.getAttribute('data-message-uuid');
+ const isMine = e.target.getAttribute('data-is-mine') === 'true';
+ if (uuid && !isMine && !markedRef.current.has(uuid)) {
+ toMark.push(uuid);
+ markedRef.current.add(uuid);
+ }
+ });
+ if (toMark.length > 0) {
+ markMessagesAsRead(chatUuid, toMark).catch(() => {});
+ }
+ },
+ { root: container, threshold: 0.5 }
+ );
+ container.querySelectorAll('[data-message-uuid]').forEach((n) => observer.observe(n));
+ return () => observer.disconnect();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chatUuid, messages]);
+
+ const loadOlder = useCallback(async () => {
+ if (!chat || loading || loadingMore || !hasMore) return;
+ const container = listRef.current;
+ if (!container) return;
+ setLoadingMore(true);
+ const prevScrollHeight = container.scrollHeight;
+ const prevScrollTop = container.scrollTop;
+ try {
+ const nextPage = page + 1;
+ const PAGE_SIZE = 30;
+ const resp = chatUuid
+ ? await getChatMessagesByUuid(chatUuid, { page: nextPage, page_size: PAGE_SIZE })
+ : await getMessages(chat.id, { page: nextPage, page_size: PAGE_SIZE });
+ const batch = (resp.results || []).slice().sort((a, b) => {
+ const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
+ const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
+ return ta - tb;
+ });
+ setMessages((prev) => {
+ const keys = new Set(prev.map((m) => m.uuid || m.id));
+ const toAdd = batch.filter((m) => !keys.has(m.uuid || m.id));
+ return [...toAdd, ...prev].sort((a, b) => {
+ const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
+ const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
+ return ta - tb;
+ });
+ });
+ setPage(nextPage);
+ setHasMore(!!resp.next);
+ } finally {
+ setTimeout(() => {
+ const c = listRef.current;
+ if (!c) return;
+ c.scrollTop = prevScrollTop + (c.scrollHeight - prevScrollHeight);
+ }, 0);
+ setLoadingMore(false);
+ }
+ }, [chat, chatUuid, hasMore, loading, loadingMore, page]);
+
+ const handleSend = async () => {
+ if (!chat || !text.trim() || sending) return;
+ const content = text.trim();
+ setText('');
+ setSending(true);
+ try {
+ const msg = await sendMessage(chat.id, content);
+ lastSentRef.current = { id: msg.id, uuid: msg.uuid };
+ const safeMsg = { ...msg, created_at: msg.created_at || new Date().toISOString() };
+ setMessages((prev) => [...prev, safeMsg]);
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ const el = listRef.current;
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
+ });
+ });
+ } catch {
+ setText(content);
+ } finally {
+ setSending(false);
+ }
+ };
+
+ if (!chat) {
+ return (
+
+ Выберите чат из списка
+
+ );
+ }
+
+ const seen = new Set();
+ const uniqueMessages = messages.filter((m) => {
+ const k = String(m.uuid ?? m.id ?? '');
+ if (!k || seen.has(k)) return false;
+ seen.add(k);
+ return true;
+ });
+
+ const grouped = [];
+ let prevDay = '';
+ uniqueMessages.forEach((m, idx) => {
+ const day = dateKey(m.created_at);
+ if (day && day !== prevDay) {
+ grouped.push({ type: 'day', key: `day-${day}`, label: formatDayHeader(m.created_at) });
+ prevDay = day;
+ }
+ const senderId = m.sender_id ?? (typeof m.sender === 'number' ? m.sender : m.sender?.id ?? null);
+ const isMine = !!currentUserId && senderId === currentUserId;
+ const isSystem =
+ m.message_type === 'system' ||
+ (typeof m.sender === 'string' && m.sender.toLowerCase() === 'system') ||
+ (!senderId && m.sender_name === 'System');
+ grouped.push({
+ type: 'msg',
+ key: m.uuid || m.id || `msg-${idx}`,
+ msg: m,
+ isMine,
+ isSystem,
+ });
+ });
+
+ return (
+
+ {/* Header */}
+
+ {onBack && (
+
+
+
+ )}
+ {getInitials(chat.participant_name)}
+
+ {chat.participant_name || 'Чат'}
+ {chat.other_is_online && (
+
+ Онлайн
+
+ )}
+
+
+
+ {/* Messages */}
+ {
+ if (e.deltaY < 0) lastWheelUpRef.current = Date.now();
+ }}
+ onScroll={(e) => {
+ const el = e.currentTarget;
+ if (el.scrollTop < 40 && Date.now() - lastWheelUpRef.current < 200) loadOlder();
+ }}
+ >
+ {loadingMore && (
+
+ Загрузка…
+
+ )}
+ {loading ? (
+
+
+
+ ) : (
+ grouped.map((item) => {
+ if (item.type === 'day') {
+ return (
+
+
+ {item.label}
+
+
+ );
+ }
+ const { msg, isMine, isSystem } = item;
+ const msgUuid = msg.uuid ? String(msg.uuid) : null;
+ return (
+
+
+ {stripHtml(msg.content || '')}
+
+
+ {formatTime(msg.created_at)}
+
+
+ );
+ })
+ )}
+
+
+ {/* Input */}
+
+
+ setText(e.target.value)}
+ placeholder="Сообщение…"
+ fullWidth
+ multiline
+ minRows={1}
+ maxRows={4}
+ size="small"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ }}
+ />
+
+ {sending ? : }
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function ChatPlatformView() {
+ const { user } = useAuthContext();
+ const [chats, setChats] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedChat, setSelectedChat] = useState(null);
+ const [newChatOpen, setNewChatOpen] = useState(false);
+ const [error, setError] = useState(null);
+ const mobileShowWindow = !!selectedChat;
+
+ const load = useCallback(async () => {
+ try {
+ const res = await getConversations({ page_size: 50 });
+ setChats(res.results);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки чатов');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleChatCreated = (chat) => {
+ setChats((prev) => {
+ const exists = prev.some((c) => c.uuid === chat.uuid || c.id === chat.id);
+ if (exists) return prev;
+ return [chat, ...prev];
+ });
+ setSelectedChat(chat);
+ };
+
+ return (
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ setNewChatOpen(true)}
+ loading={loading}
+ />
+
+
+
+ setSelectedChat(null)}
+ />
+
+
+
+ setNewChatOpen(false)}
+ onCreated={handleChatCreated}
+ />
+
+ );
+}
diff --git a/front_minimal/src/sections/chat/view/index.js b/front_minimal/src/sections/chat/view/index.js
index ecfab5f..39ed3fd 100644
--- a/front_minimal/src/sections/chat/view/index.js
+++ b/front_minimal/src/sections/chat/view/index.js
@@ -1 +1,2 @@
export * from './chat-view';
+export { ChatPlatformView } from './chat-platform-view';
diff --git a/front_minimal/src/sections/children/view/children-progress-view.jsx b/front_minimal/src/sections/children/view/children-progress-view.jsx
new file mode 100644
index 0000000..6960999
--- /dev/null
+++ b/front_minimal/src/sections/children/view/children-progress-view.jsx
@@ -0,0 +1,180 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Button from '@mui/material/Button';
+import Typography from '@mui/material/Typography';
+import CardContent from '@mui/material/CardContent';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import axios from 'src/utils/axios';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+// ----------------------------------------------------------------------
+
+async function getChildProgress(childId) {
+ const params = childId ? `?child_id=${childId}` : '';
+ const res = await axios.get(`/users/student-progress/${params}`);
+ return res.data;
+}
+
+// ----------------------------------------------------------------------
+
+function StatCard({ label, value, icon, color }) {
+ return (
+
+
+
+
+
+
+
+ {value ?? 0}
+
+
+
+ {label}
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function ChildrenProgressView() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const childId = searchParams.get('child');
+
+ const [progress, setProgress] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!childId) return;
+ try {
+ setLoading(true);
+ const data = await getChildProgress(childId);
+ setProgress(data);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки прогресса');
+ } finally {
+ setLoading(false);
+ }
+ }, [childId]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ if (!childId) {
+ return (
+
+
+
+
+ Выберите ребёнка для просмотра прогресса
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : progress ? (
+
+
+
+ {progress.attendance_rate !== undefined && (
+
+ )}
+ {progress.avg_grade !== undefined && progress.avg_grade > 0 && (
+
+ )}
+
+ ) : (
+
+ Нет данных о прогрессе
+
+ )}
+
+ );
+}
diff --git a/front_minimal/src/sections/children/view/children-view.jsx b/front_minimal/src/sections/children/view/children-view.jsx
new file mode 100644
index 0000000..1079e6c
--- /dev/null
+++ b/front_minimal/src/sections/children/view/children-view.jsx
@@ -0,0 +1,123 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Grid from '@mui/material/Grid';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Avatar from '@mui/material/Avatar';
+import Button from '@mui/material/Button';
+import Typography from '@mui/material/Typography';
+import CardContent from '@mui/material/CardContent';
+import CardActions from '@mui/material/CardActions';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import axios from 'src/utils/axios';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+// ----------------------------------------------------------------------
+
+async function getChildren() {
+ const res = await axios.get('/users/parents/children/');
+ const {data} = res;
+ if (Array.isArray(data)) return data;
+ return data?.results ?? [];
+}
+
+// ----------------------------------------------------------------------
+
+export function ChildrenView() {
+ const router = useRouter();
+ const [children, setChildren] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ setLoading(true);
+ const list = await getChildren();
+ setChildren(list);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : children.length === 0 ? (
+
+
+
+ Нет привязанных детей
+
+
+ ) : (
+
+ {children.map((child) => {
+ const name = `${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email;
+ return (
+
+
+
+
+
+ {name[0]?.toUpperCase()}
+
+
+ {name}
+
+ {child.email}
+
+
+
+
+
+ }
+ onClick={() => router.push(`${paths.dashboard.childrenProgress}?child=${child.id}`)}
+ >
+ Прогресс
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/front_minimal/src/sections/children/view/index.js b/front_minimal/src/sections/children/view/index.js
new file mode 100644
index 0000000..fbda3d5
--- /dev/null
+++ b/front_minimal/src/sections/children/view/index.js
@@ -0,0 +1,2 @@
+export { ChildrenView } from './children-view';
+export { ChildrenProgressView } from './children-progress-view';
diff --git a/front_minimal/src/sections/contact/contact-map.jsx b/front_minimal/src/sections/contact/contact-map.jsx
index 6691630..9d52672 100644
--- a/front_minimal/src/sections/contact/contact-map.jsx
+++ b/front_minimal/src/sections/contact/contact-map.jsx
@@ -1,5 +1,4 @@
-import { useState } from 'react';
-import dynamic from 'next/dynamic';
+import { lazy, Suspense, useState } from 'react';
import Box from '@mui/material/Box';
import Skeleton from '@mui/material/Skeleton';
@@ -11,14 +10,7 @@ import { MapPopup, MapMarker, MapControl } from 'src/components/map';
// ----------------------------------------------------------------------
-const Map = dynamic(() => import('src/components/map').then((mod) => mod.Map), {
- loading: () => (
-
- ),
-});
+const Map = lazy(() => import('src/components/map').then((mod) => ({ default: mod.Map })));
// ----------------------------------------------------------------------
@@ -39,6 +31,14 @@ export function ContactMap({ contacts }) {
height: { xs: 320, md: 560 },
}}
>
+
+ }
+ >
+
);
}
diff --git a/front_minimal/src/sections/feedback/view/feedback-view.jsx b/front_minimal/src/sections/feedback/view/feedback-view.jsx
new file mode 100644
index 0000000..76a210d
--- /dev/null
+++ b/front_minimal/src/sections/feedback/view/feedback-view.jsx
@@ -0,0 +1,339 @@
+'use client';
+
+import { useState, useEffect, useMemo, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Paper from '@mui/material/Paper';
+import Button from '@mui/material/Button';
+import Drawer from '@mui/material/Drawer';
+import Divider from '@mui/material/Divider';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { Iconify } from 'src/components/iconify';
+import { DashboardContent } from 'src/layouts/dashboard';
+import { useAuthContext } from 'src/auth/hooks';
+import { getLessons, completeLesson } from 'src/utils/dashboard-api';
+
+// ----------------------------------------------------------------------
+
+function getSubjectName(lesson) {
+ if (typeof lesson.subject === 'string') return lesson.subject;
+ if (lesson.subject?.name) return lesson.subject.name;
+ return lesson.subject_name || 'Занятие';
+}
+
+function getClientName(lesson) {
+ if (typeof lesson.client === 'object' && lesson.client?.user) {
+ return `${lesson.client.user.first_name} ${lesson.client.user.last_name}`;
+ }
+ return lesson.client_name || 'Студент';
+}
+
+function formatDate(s) {
+ return new Date(s).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
+}
+
+function formatTime(s) {
+ return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+}
+
+// ----------------------------------------------------------------------
+
+function FeedbackDrawer({ open, lesson, onClose, onSuccess }) {
+ const [form, setForm] = useState({ mentor_grade: '', school_grade: '', mentor_notes: '' });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (open && lesson) {
+ setForm({
+ mentor_grade: lesson.mentor_grade?.toString() || '',
+ school_grade: lesson.school_grade?.toString() || '',
+ mentor_notes: lesson.mentor_notes || '',
+ });
+ setError(null);
+ }
+ }, [open, lesson]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+ try {
+ await completeLesson(
+ lesson.id,
+ form.mentor_notes.trim(),
+ form.mentor_grade ? parseInt(form.mentor_grade, 10) : undefined,
+ form.school_grade ? parseInt(form.school_grade, 10) : undefined,
+ );
+ onSuccess();
+ onClose();
+ } catch (err) {
+ setError(err?.response?.data?.detail || err?.message || 'Ошибка сохранения');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Обратная связь
+ {lesson && (
+
+ {lesson.title} — {getSubjectName(lesson)}
+
+ )}
+
+
+
+
+
+
+ {lesson && (
+
+
+
+
+ Студент
+ {getClientName(lesson)}
+
+
+ Дата
+ {formatDate(lesson.start_time)}
+
+
+ Время
+ {formatTime(lesson.start_time)} — {formatTime(lesson.end_time)}
+
+
+
+
+ Оценки
+
+ setForm((p) => ({ ...p, mentor_grade: e.target.value }))}
+ disabled={loading}
+ size="small"
+ fullWidth
+ />
+ setForm((p) => ({ ...p, school_grade: e.target.value }))}
+ disabled={loading}
+ size="small"
+ fullWidth
+ />
+
+
+ setForm((p) => ({ ...p, mentor_notes: e.target.value }))}
+ disabled={loading}
+ placeholder="Что прошли на занятии, успехи студента, рекомендации..."
+ />
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function LessonCard({ lesson, onFill }) {
+ const hasFeedback = !!(lesson.mentor_grade || lesson.school_grade || lesson.mentor_notes?.trim());
+
+ return (
+
+
+
+ {lesson.title}
+
+
+
+ {getClientName(lesson)}
+
+
+
+ {formatDate(lesson.start_time)}
+
+
+
+ {formatTime(lesson.start_time)} — {formatTime(lesson.end_time)}
+
+ {lesson.mentor_grade != null && (
+
+ Оценка: {lesson.mentor_grade}/5
+
+ )}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function FeedbackView() {
+ const { user } = useAuthContext();
+ const [lessons, setLessons] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selected, setSelected] = useState(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ const load = useCallback(async () => {
+ if (user?.role !== 'mentor') return;
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await getLessons({ status: 'completed' });
+ const list = Array.isArray(res) ? res : res?.results || [];
+ setLessons(list.filter((l) => !l.group));
+ } catch (err) {
+ setError(err?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, [user?.role]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const todoLessons = useMemo(
+ () => lessons.filter((l) => !(l.mentor_grade || l.school_grade || l.mentor_notes?.trim())),
+ [lessons],
+ );
+ const doneLessons = useMemo(
+ () => lessons.filter((l) => !!(l.mentor_grade || l.school_grade || l.mentor_notes?.trim())),
+ [lessons],
+ );
+
+ if (user?.role !== 'mentor') {
+ return (
+
+
+ Страница доступна только менторам.
+
+
+ );
+ }
+
+ return (
+
+
+ Обратная связь
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {/* Ожидают */}
+
+
+ Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''}
+
+
+ {todoLessons.map((l) => (
+ { setSelected(l); setDrawerOpen(true); }}
+ />
+ ))}
+ {todoLessons.length === 0 && (
+
+ Нет занятий, ожидающих обратной связи
+
+ )}
+
+
+
+
+
+ {/* Заполнено */}
+
+
+ Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''}
+
+
+ {doneLessons.map((l) => (
+ { setSelected(l); setDrawerOpen(true); }}
+ />
+ ))}
+ {doneLessons.length === 0 && (
+
+ Нет завершённых занятий
+
+ )}
+
+
+
+ )}
+
+ setDrawerOpen(false)}
+ onSuccess={load}
+ />
+
+ );
+}
diff --git a/front_minimal/src/sections/feedback/view/index.js b/front_minimal/src/sections/feedback/view/index.js
new file mode 100644
index 0000000..4fec362
--- /dev/null
+++ b/front_minimal/src/sections/feedback/view/index.js
@@ -0,0 +1 @@
+export * from './feedback-view';
diff --git a/front_minimal/src/sections/my-progress/view/index.js b/front_minimal/src/sections/my-progress/view/index.js
new file mode 100644
index 0000000..9a7c1a3
--- /dev/null
+++ b/front_minimal/src/sections/my-progress/view/index.js
@@ -0,0 +1 @@
+export { MyProgressView } from './my-progress-view';
diff --git a/front_minimal/src/sections/my-progress/view/my-progress-view.jsx b/front_minimal/src/sections/my-progress/view/my-progress-view.jsx
new file mode 100644
index 0000000..22a2a09
--- /dev/null
+++ b/front_minimal/src/sections/my-progress/view/my-progress-view.jsx
@@ -0,0 +1,354 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import { useMemo, useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Select from '@mui/material/Select';
+import MenuItem from '@mui/material/MenuItem';
+import Typography from '@mui/material/Typography';
+import InputLabel from '@mui/material/InputLabel';
+import FormControl from '@mui/material/FormControl';
+import CardContent from '@mui/material/CardContent';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import axios from 'src/utils/axios';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+import { useAuthContext } from 'src/auth/hooks';
+
+// ----------------------------------------------------------------------
+
+const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
+
+// ----------------------------------------------------------------------
+
+function getDatesInRange(startStr, endStr) {
+ const dates = [];
+ let d = new Date(startStr);
+ const end = new Date(endStr);
+ while (d <= end) {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ dates.push(`${y}-${m}-${day}`);
+ d = new Date(d.getTime() + 86400000);
+ }
+ return dates;
+}
+
+function formatDateLabel(d) {
+ const [, m, day] = d.split('-');
+ return `${day}.${m}`;
+}
+
+async function getLessons(params) {
+ const q = new URLSearchParams();
+ if (params?.start_date) q.append('start_date', params.start_date);
+ if (params?.end_date) q.append('end_date', params.end_date);
+ if (params?.child_id) q.append('child_id', params.child_id);
+ const res = await axios.get(`/lessons/lessons/?${q.toString()}`);
+ const {data} = res;
+ if (Array.isArray(data)) return { results: data };
+ return data;
+}
+
+// ----------------------------------------------------------------------
+
+function StatTile({ label, value, sub, icon, color }) {
+ return (
+
+
+
+
+
+
+
+ {value}
+
+
+
+ {label}
+
+ {sub && (
+
+ {sub}
+
+ )}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+const TODAY = new Date().toISOString().slice(0, 10);
+const WEEK_AGO = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
+
+export function MyProgressView() {
+ const { user } = useAuthContext();
+ const isParent = user?.role === 'parent';
+
+ const [startDate, setStartDate] = useState(WEEK_AGO);
+ const [endDate, setEndDate] = useState(TODAY);
+ const [lessons, setLessons] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [subjects, setSubjects] = useState([]);
+ const [selectedSubject, setSelectedSubject] = useState('');
+
+ const childId = isParent
+ ? typeof window !== 'undefined'
+ ? localStorage.getItem('selected_child_id') || ''
+ : ''
+ : '';
+
+ const loadSubjects = useCallback(async () => {
+ const sixMonthsAgo = new Date(Date.now() - 180 * 86400000).toISOString().slice(0, 10);
+ const res = await getLessons({
+ start_date: sixMonthsAgo,
+ end_date: TODAY,
+ ...(childId ? { child_id: childId } : {}),
+ });
+ const set = new Set();
+ (res.results || []).forEach((l) => {
+ if (l.subject && typeof l.subject === 'string' && l.subject.trim()) {
+ set.add(l.subject.trim());
+ }
+ });
+ const list = Array.from(set).sort();
+ setSubjects(list);
+ if (list.length > 0) setSelectedSubject(list[0]);
+ }, [childId]);
+
+ useEffect(() => {
+ loadSubjects();
+ }, [loadSubjects]);
+
+ const load = useCallback(async () => {
+ try {
+ setLoading(true);
+ const res = await getLessons({
+ start_date: startDate,
+ end_date: endDate,
+ ...(childId ? { child_id: childId } : {}),
+ });
+ const filtered = (res.results || []).filter((l) =>
+ selectedSubject ? (l.subject || '').trim() === selectedSubject : true
+ );
+ filtered.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
+ setLessons(filtered);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, [startDate, endDate, selectedSubject, childId]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const stats = useMemo(() => {
+ const completed = lessons.filter((l) => l.status === 'completed').length;
+ const total = lessons.length;
+ const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0;
+ let sum = 0;
+ let count = 0;
+ lessons.forEach((l) => {
+ if (l.status === 'completed') {
+ if (l.mentor_grade != null) {
+ sum += l.mentor_grade;
+ count += 1;
+ }
+ if (l.school_grade != null) {
+ sum += l.school_grade;
+ count += 1;
+ }
+ }
+ });
+ const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
+ return { completed, total, attendanceRate, avgGrade };
+ }, [lessons]);
+
+ const chartData = useMemo(() => {
+ const allDates = getDatesInRange(startDate, endDate);
+ const categories = allDates.map(formatDateLabel);
+ const byDate = {};
+ lessons.forEach((l) => {
+ if (l.status !== 'completed') return;
+ const key = (l.start_time || '').slice(0, 10);
+ if (!key) return;
+ byDate[key] = (byDate[key] || 0) + 1;
+ });
+ const data = allDates.map((d) => byDate[d] || 0);
+ const gradeByDate = {};
+ lessons.forEach((l) => {
+ if (l.status !== 'completed') return;
+ const key = (l.start_time || '').slice(0, 10);
+ if (!key) return;
+ if (l.mentor_grade != null) gradeByDate[key] = l.mentor_grade;
+ });
+ const grades = allDates.map((d) => gradeByDate[d] ?? null);
+ return { categories, attendanceSeries: [{ name: 'Занятия', data }], gradesSeries: [{ name: 'Оценка', data: grades }] };
+ }, [lessons, startDate, endDate]);
+
+ const chartOptionsBase = {
+ chart: { toolbar: { show: false } },
+ stroke: { curve: 'smooth', width: 2 },
+ dataLabels: { enabled: false },
+ grid: { borderColor: '#f0f0f0' },
+ xaxis: { categories: chartData.categories, axisBorder: { show: false }, axisTicks: { show: false } },
+ };
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Controls */}
+
+
+ Предмет
+
+
+
+
+ От:
+
+ setStartDate(e.target.value)}
+ style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }}
+ />
+
+ До:
+
+ setEndDate(e.target.value)}
+ style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }}
+ />
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {/* Stats row */}
+
+
+
+
+
+
+ {/* Attendance chart */}
+ {typeof window !== 'undefined' && chartData.categories.length > 0 && (
+
+
+
+ Посещаемость
+
+
+
+
+ )}
+
+ {/* Grades chart */}
+ {typeof window !== 'undefined' && chartData.categories.length > 0 && (
+
+
+
+ Оценки
+
+
+
+
+ )}
+
+ {lessons.length === 0 && (
+
+ Нет занятий за выбранный период
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/front_minimal/src/sections/overview/client/view/index.js b/front_minimal/src/sections/overview/client/view/index.js
new file mode 100644
index 0000000..a873866
--- /dev/null
+++ b/front_minimal/src/sections/overview/client/view/index.js
@@ -0,0 +1 @@
+export * from './overview-client-view';
diff --git a/front_minimal/src/sections/overview/client/view/overview-client-view.jsx b/front_minimal/src/sections/overview/client/view/overview-client-view.jsx
new file mode 100644
index 0000000..0e3712e
--- /dev/null
+++ b/front_minimal/src/sections/overview/client/view/overview-client-view.jsx
@@ -0,0 +1,389 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Stack from '@mui/material/Stack';
+import Divider from '@mui/material/Divider';
+import Avatar from '@mui/material/Avatar';
+import Typography from '@mui/material/Typography';
+import CircularProgress from '@mui/material/CircularProgress';
+import LinearProgress from '@mui/material/LinearProgress';
+import { useTheme } from '@mui/material/styles';
+
+import { fDateTime } from 'src/utils/format-time';
+import { getClientDashboard, getChildDashboard } from 'src/utils/dashboard-api';
+import { DashboardContent } from 'src/layouts/dashboard';
+import { useAuthContext } from 'src/auth/hooks';
+import { CONFIG } from 'src/config-global';
+import { varAlpha } from 'src/theme/styles';
+import { Iconify } from 'src/components/iconify';
+
+import { CourseWidgetSummary } from '../../course/course-widget-summary';
+import { CourseProgress } from '../../course/course-progress';
+import { CourseMyAccount } from '../../course/course-my-account';
+
+// ----------------------------------------------------------------------
+
+const formatDateTime = (str) => {
+ if (!str) return '—';
+ try {
+ const date = new Date(str);
+ if (isNaN(date.getTime())) return '—';
+ const today = new Date();
+ const tomorrow = new Date(today);
+ tomorrow.setDate(today.getDate() + 1);
+ const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+ if (date.toDateString() === today.toDateString()) return `Сегодня, ${time}`;
+ if (date.toDateString() === tomorrow.toDateString()) return `Завтра, ${time}`;
+ return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
+ } catch {
+ return '—';
+ }
+};
+
+// ----------------------------------------------------------------------
+
+function LessonItem({ lesson }) {
+ const mentorName = lesson.mentor
+ ? `${lesson.mentor.first_name || ''} ${lesson.mentor.last_name || ''}`.trim() || 'Ментор'
+ : 'Ментор';
+
+ return (
+
+
+ {mentorName[0]?.toUpperCase()}
+
+
+
+ {lesson.title || lesson.subject || 'Занятие'}
+
+
+ {mentorName}
+
+
+
+ {formatDateTime(lesson.start_time)}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function HomeworkItem({ homework }) {
+ const statusColor = {
+ pending: 'warning',
+ submitted: 'info',
+ reviewed: 'success',
+ completed: 'success',
+ }[homework.status] || 'default';
+
+ return (
+
+
+
+
+
+
+ {homework.title}
+
+
+ {homework.subject || 'Предмет не указан'}
+
+
+ {homework.grade != null && (
+
+ {homework.grade}/5
+
+ )}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function OverviewClientView({ childId, childName }) {
+ const theme = useTheme();
+ const { user } = useAuthContext();
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const fetchData = useCallback(
+ async (signal) => {
+ try {
+ setLoading(true);
+ const data = childId
+ ? await getChildDashboard(childId, { signal })
+ : await getClientDashboard({ signal });
+ if (!signal?.aborted) setStats(data);
+ } catch (err) {
+ if (err?.name === 'AbortError' || err?.name === 'CanceledError') return;
+ console.error('Client dashboard error:', err);
+ } finally {
+ if (!signal?.aborted) setLoading(false);
+ }
+ },
+ [childId]
+ );
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetchData(controller.signal);
+ return () => controller.abort();
+ }, [fetchData]);
+
+ const displayName = childName || user?.first_name || 'Студент';
+
+ const completionPct =
+ stats?.total_lessons > 0
+ ? Math.round((stats.completed_lessons / stats.total_lessons) * 100)
+ : 0;
+
+ if (loading && !stats) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* LEFT */}
+
+ {/* Greeting */}
+
+
+ Привет, {displayName}! 👋
+
+
+ Твой прогресс и ближайшие занятия.
+
+
+
+ {/* Stat widgets */}
+
+
+
+
+
+
+
+ {/* Progress bar */}
+
+
+ Прогресс занятий
+
+ {completionPct}%
+
+
+
+
+
+ Пройдено: {stats?.completed_lessons || 0}
+
+
+ Всего: {stats?.total_lessons || 0}
+
+
+
+
+ {/* Upcoming lessons + homework */}
+
+ {/* Upcoming lessons */}
+
+
+ Ближайшие занятия
+
+
+
+ {loading ? (
+
+
+
+ ) : stats?.upcoming_lessons?.length > 0 ? (
+
+ {stats.upcoming_lessons.slice(0, 4).map((lesson) => (
+
+ ))}
+
+ ) : (
+
+
+ Нет запланированных занятий
+
+ )}
+
+
+ {/* Homework */}
+
+
+ Домашние задания
+
+
+
+ {loading ? (
+
+
+
+ ) : stats?.recent_homework?.length > 0 ? (
+
+ {stats.recent_homework.slice(0, 4).map((hw) => (
+
+ ))}
+
+ ) : (
+
+
+ Нет домашних заданий
+
+ )}
+
+
+
+
+ {/* RIGHT sidebar */}
+
+
+
+ {/* Next lesson highlight */}
+ {stats?.next_lesson && (
+
+
+ Следующее занятие
+
+
+ {stats.next_lesson.title || 'Занятие'}
+
+
+ {formatDateTime(stats.next_lesson.start_time)}
+
+ {stats.next_lesson.mentor && (
+
+
+ {(stats.next_lesson.mentor.first_name || 'М')[0]}
+
+
+ {`${stats.next_lesson.mentor.first_name || ''} ${stats.next_lesson.mentor.last_name || ''}`.trim()}
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/front_minimal/src/sections/payment/view/index.js b/front_minimal/src/sections/payment/view/index.js
index b890618..7d00f32 100644
--- a/front_minimal/src/sections/payment/view/index.js
+++ b/front_minimal/src/sections/payment/view/index.js
@@ -1 +1,2 @@
export * from './payment-view';
+export { PaymentPlatformView } from './payment-platform-view';
diff --git a/front_minimal/src/sections/payment/view/payment-platform-view.jsx b/front_minimal/src/sections/payment/view/payment-platform-view.jsx
new file mode 100644
index 0000000..c4c470e
--- /dev/null
+++ b/front_minimal/src/sections/payment/view/payment-platform-view.jsx
@@ -0,0 +1,205 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Button from '@mui/material/Button';
+import Divider from '@mui/material/Divider';
+import Typography from '@mui/material/Typography';
+import CardContent from '@mui/material/CardContent';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import axios from 'src/utils/axios';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+// ----------------------------------------------------------------------
+
+async function getPaymentInfo() {
+ const res = await axios.get('/payments/subscriptions/');
+ const {data} = res;
+ if (Array.isArray(data)) return data;
+ return data?.results ?? [];
+}
+
+async function getPaymentHistory() {
+ const res = await axios.get('/payments/history/');
+ const {data} = res;
+ if (Array.isArray(data)) return data;
+ return data?.results ?? [];
+}
+
+// ----------------------------------------------------------------------
+
+function formatDate(ts) {
+ if (!ts) return '—';
+ try {
+ return new Date(ts).toLocaleDateString('ru-RU');
+ } catch {
+ return ts;
+ }
+}
+
+function formatAmount(amount, currency) {
+ if (amount == null) return '—';
+ return `${amount} ${currency || '₽'}`;
+}
+
+// ----------------------------------------------------------------------
+
+export function PaymentPlatformView() {
+ const [subscriptions, setSubscriptions] = useState([]);
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ setLoading(true);
+ const [subs, hist] = await Promise.all([
+ getPaymentInfo().catch(() => []),
+ getPaymentHistory().catch(() => []),
+ ]);
+ setSubscriptions(subs);
+ setHistory(hist);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {/* Subscriptions */}
+ {subscriptions.length > 0 ? (
+
+
+
+ Активные подписки
+
+
+ {subscriptions.map((sub, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ {idx > 0 && }
+
+
+
+ {sub.plan_name || sub.name || 'Подписка'}
+
+ {sub.expires_at && (
+
+ До {formatDate(sub.expires_at)}
+
+ )}
+
+
+ {sub.status && (
+
+ )}
+ {sub.price != null && (
+
+ {formatAmount(sub.price, sub.currency)}
+
+ )}
+
+
+
+ ))}
+
+
+
+ ) : (
+
+
+
+
+
+ Нет активных подписок
+
+ }>
+ Подключить подписку
+
+
+
+
+ )}
+
+ {/* Payment history */}
+ {history.length > 0 && (
+
+
+
+ История платежей
+
+
+ {history.map((item, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+
+ {item.description || item.plan_name || 'Платёж'}
+
+
+ {formatDate(item.created_at || item.date)}
+
+
+
+
+ {formatAmount(item.amount, item.currency)}
+
+ {item.status && (
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/front_minimal/src/sections/referrals/view/index.js b/front_minimal/src/sections/referrals/view/index.js
new file mode 100644
index 0000000..aae1a65
--- /dev/null
+++ b/front_minimal/src/sections/referrals/view/index.js
@@ -0,0 +1 @@
+export { ReferralsView } from './referrals-view';
diff --git a/front_minimal/src/sections/referrals/view/referrals-view.jsx b/front_minimal/src/sections/referrals/view/referrals-view.jsx
new file mode 100644
index 0000000..cc7869e
--- /dev/null
+++ b/front_minimal/src/sections/referrals/view/referrals-view.jsx
@@ -0,0 +1,298 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Button from '@mui/material/Button';
+import Divider from '@mui/material/Divider';
+import Tooltip from '@mui/material/Tooltip';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import CardContent from '@mui/material/CardContent';
+import InputAdornment from '@mui/material/InputAdornment';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import { getMyReferrals, getReferralStats, getReferralProfile } from 'src/utils/referrals-api';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+// ----------------------------------------------------------------------
+
+function StatCard({ label, value, icon, color }) {
+ return (
+
+
+
+
+
+
+
+ {value ?? '—'}
+
+
+
+ {label}
+
+
+
+ );
+}
+
+function ReferralTable({ title, items }) {
+ if (!items || items.length === 0) return null;
+ return (
+
+
+ {title}
+
+
+ {items.map((item, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ {item.email}
+
+
+
+ {item.total_points} pts
+
+
+
+ ))}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function ReferralsView() {
+ const [profile, setProfile] = useState(null);
+ const [stats, setStats] = useState(null);
+ const [referrals, setReferrals] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ setLoading(true);
+ const [p, s, r] = await Promise.all([
+ getReferralProfile(),
+ getReferralStats(),
+ getMyReferrals().catch(() => null),
+ ]);
+ setProfile(p);
+ setStats(s);
+ setReferrals(r);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleCopyLink = () => {
+ const link = profile?.referral_link || stats?.referral_code || '';
+ if (!link) return;
+ navigator.clipboard.writeText(link).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ const handleCopyCode = () => {
+ const code = profile?.referral_code || stats?.referral_code || '';
+ if (!code) return;
+ navigator.clipboard.writeText(code).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ const referralCode = profile?.referral_code || stats?.referral_code || '';
+ const referralLink = profile?.referral_link || '';
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {/* Stats */}
+ {stats && (
+
+
+
+
+
+
+
+ )}
+
+ {/* Level */}
+ {stats?.current_level && (
+
+
+
+
+
+
+ Уровень {stats.current_level.level} — {stats.current_level.name}
+
+
+ Ваш текущий реферальный уровень
+
+
+
+
+
+ )}
+
+ {/* Referral code & link */}
+ {referralCode && (
+
+
+
+ Ваш реферальный код
+
+
+
+
+
+
+
+
+
+ ),
+ }}
+ fullWidth
+ size="small"
+ />
+ {referralLink && (
+
+
+
+
+
+
+
+ ),
+ }}
+ fullWidth
+ size="small"
+ />
+ )}
+ }
+ onClick={handleCopyLink || handleCopyCode}
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ Поделиться
+
+
+
+
+ )}
+
+ {/* Referrals list */}
+ {referrals && (referrals.direct?.length > 0 || referrals.indirect?.length > 0) && (
+
+
+
+ Мои рефералы
+
+
+
+ {referrals.direct?.length > 0 && referrals.indirect?.length > 0 && }
+
+
+
+
+ )}
+
+ {!referralCode && !loading && (
+
+ Реферальная программа недоступна
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/front_minimal/src/sections/video-call/livekit/exit-lesson-modal.jsx b/front_minimal/src/sections/video-call/livekit/exit-lesson-modal.jsx
new file mode 100644
index 0000000..e89e697
--- /dev/null
+++ b/front_minimal/src/sections/video-call/livekit/exit-lesson-modal.jsx
@@ -0,0 +1,423 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+import { createHomework } from 'src/utils/homework-api';
+import { completeLesson, uploadLessonFile } from 'src/utils/dashboard-api';
+
+// ----------------------------------------------------------------------
+
+const MAX_LESSON_FILES = 10;
+const MAX_FILE_SIZE_MB = 10;
+
+function formatSize(bytes) {
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} МБ`;
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
+ return `${bytes} Б`;
+}
+
+function getFileKind(file) {
+ const t = file.type?.toLowerCase() ?? '';
+ const name = file.name.toLowerCase();
+ if (t.startsWith('image/') || /\.(jpe?g|png|gif|webp|bmp|svg|ico)$/i.test(name)) return 'image';
+ if (t.startsWith('video/') || /\.(mp4|webm|ogg|mov|avi|mkv)$/i.test(name)) return 'video';
+ if (t === 'application/pdf' || name.endsWith('.pdf')) return 'pdf';
+ return 'other';
+}
+
+function FilePreviewChip({ file, onRemove, disabled }) {
+ const kind = getFileKind(file);
+ const [objectUrl, setObjectUrl] = useState(null);
+ const name = file.name.length > 28 ? `${file.name.slice(0, 25)}…` : file.name;
+
+ useEffect(() => {
+ if (kind === 'image' || kind === 'video' || kind === 'pdf') {
+ const url = URL.createObjectURL(file);
+ setObjectUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }
+ return undefined;
+ }, [file, kind]);
+
+ let previewBlock;
+ if (kind === 'image' && objectUrl) {
+ previewBlock = (
+
+ );
+ } else if (kind === 'video' && objectUrl) {
+ previewBlock = (
+
+ );
+ } else if (kind === 'pdf' && objectUrl) {
+ previewBlock = (
+
+ );
+ } else {
+ previewBlock = (
+
+ 📄
+
+ );
+ }
+
+ return (
+
+
{previewBlock}
+
+
+
{name}
+
{formatSize(file.size)}
+
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function ExitLessonModal({ isOpen, lessonId, onClose, onExit }) {
+ const [step, setStep] = useState('choose_exit');
+ const [formData, setFormData] = useState({ mentor_grade: '', school_grade: '', mentor_notes: '' });
+ const [homeworkText, setHomeworkText] = useState('');
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [mounted, setMounted] = useState(false);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ setMounted(true);
+ const t = requestAnimationFrame(() => {
+ requestAnimationFrame(() => setVisible(true));
+ });
+ return () => cancelAnimationFrame(t);
+ }
+ setVisible(false);
+ return undefined;
+ }, [isOpen]);
+
+ const reset = () => {
+ setStep('choose_exit');
+ setFormData({ mentor_grade: '', school_grade: '', mentor_notes: '' });
+ setHomeworkText('');
+ setFiles([]);
+ setError(null);
+ };
+
+ const handleClose = () => {
+ if (!loading) {
+ reset();
+ onClose();
+ }
+ };
+
+ const handleTransitionEnd = (e) => {
+ if (e.propertyName !== 'transform') return;
+ if (!isOpen) setMounted(false);
+ };
+
+ if (!mounted) return null;
+
+ const notes = (formData.mentor_notes || '').trim();
+ const mentorGrade = formData.mentor_grade ? parseInt(formData.mentor_grade, 10) : undefined;
+ const schoolGrade = formData.school_grade ? parseInt(formData.school_grade, 10) : undefined;
+
+ const handleJustExit = () => {
+ reset();
+ onClose();
+ onExit();
+ };
+
+ const handleCompleteNoHw = async () => {
+ if (lessonId == null) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, undefined);
+ if (!result?.success) {
+ setError(result?.message || 'Не удалось завершить занятие.');
+ return;
+ }
+ reset();
+ onClose();
+ onExit();
+ } catch (e) {
+ setError(e?.message || 'Не удалось завершить занятие');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCompleteHwLater = async () => {
+ if (lessonId == null) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const completeResult = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, undefined);
+ if (!completeResult?.success) {
+ setError(completeResult?.message || 'Не удалось завершить занятие.');
+ return;
+ }
+ await createHomework({ title: 'ДЗ (заполнить позже)', description: '', lesson_id: lessonId, status: 'draft', fill_later: true });
+ reset();
+ onClose();
+ onExit();
+ } catch (e) {
+ setError(e?.message || 'Не удалось завершить занятие');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSaveHw = async () => {
+ if (lessonId == null) return;
+ const text = homeworkText.trim();
+ if (!text && files.length === 0) {
+ setError('Добавьте текст или прикрепите файлы');
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const lessonFileIds = [];
+ // eslint-disable-next-line no-restricted-syntax
+ for (const file of files) {
+ // eslint-disable-next-line no-await-in-loop
+ const created = await uploadLessonFile(lessonId, file);
+ const id = typeof created.id === 'number' ? created.id : parseInt(String(created.id), 10);
+ if (!Number.isNaN(id)) lessonFileIds.push(id);
+ }
+ await createHomework({ title: 'Домашнее задание', description: text || '', lesson_id: lessonId, status: 'published' });
+ const result = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, text || undefined, true, lessonFileIds);
+ if (!result?.success) {
+ setError(result?.message || 'Занятие не удалось завершить.');
+ return;
+ }
+ reset();
+ onClose();
+ onExit();
+ } catch (e) {
+ setError(e?.message || 'Не удалось сохранить ДЗ и завершить занятие');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleFileChange = (e) => {
+ const { files: list } = e.target;
+ if (!list?.length) return;
+ const add = [];
+ for (let i = 0; i < list.length; i += 1) {
+ const f = list[i];
+ if (f.size <= MAX_FILE_SIZE_MB * 1024 * 1024) add.push(f);
+ }
+ setFiles((prev) => [...prev, ...add].slice(0, MAX_LESSON_FILES));
+ e.target.value = '';
+ };
+
+ const stepTitle = {
+ choose_exit: 'Выйти из занятия',
+ grades: 'Оценки',
+ comment: 'Комментарий',
+ choose_hw: 'Завершить занятие',
+ hw_form: 'Домашнее задание',
+ }[step] || '';
+
+ const panelStyle = {
+ position: 'fixed',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: 420,
+ background: '#fff',
+ boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
+ overflow: 'hidden',
+ display: 'flex',
+ flexDirection: 'column',
+ zIndex: 10002,
+ transform: isOpen && visible ? 'translateX(0)' : 'translateX(100%)',
+ transition: 'transform 0.3s ease',
+ };
+
+ const btnBase = { padding: '14px 20px', borderRadius: 14, fontSize: 15, cursor: loading ? 'not-allowed' : 'pointer' };
+ const btnPrimary = { ...btnBase, border: 'none', background: '#1976d2', color: '#fff', fontWeight: 600 };
+ const btnSecondary = { ...btnBase, border: '1px solid #ddd', background: '#f5f5f5', color: '#333' };
+
+ return (
+
+
+
{stepTitle}
+
+
+
+
+ {step === 'choose_exit' && (
+
+
+ {lessonId != null && (
+
+ )}
+
+ )}
+
+ {step === 'grades' && (
+
+
Необязательно
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ setFormData((d) => ({ ...d, mentor_grade: e.target.value }))}
+ style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15 }}
+ />
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ setFormData((d) => ({ ...d, school_grade: e.target.value }))}
+ style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15 }}
+ />
+
+
+
+
+
+
+ )}
+
+ {step === 'comment' && (
+
+
Комментарий к занятию (необязательно)
+
+ )}
+
+ {step === 'choose_hw' && (
+ <>
+
Выдать домашнее задание?
+
+
+
+
+
+
+ >
+ )}
+
+ {step === 'hw_form' && (
+ <>
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+
+
+
+ Файлы (до {MAX_FILE_SIZE_MB} МБ, не более {MAX_LESSON_FILES} шт.)
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {files.length > 0 && (
+
+ {files.map((f, i) => (
+ setFiles((p) => p.filter((_, j) => j !== i))}
+ disabled={loading}
+ />
+ ))}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/front_minimal/src/sections/video-call/view/index.js b/front_minimal/src/sections/video-call/view/index.js
new file mode 100644
index 0000000..84ed7fb
--- /dev/null
+++ b/front_minimal/src/sections/video-call/view/index.js
@@ -0,0 +1 @@
+export { VideoCallView } from './video-call-view';
diff --git a/front_minimal/src/sections/video-call/view/video-call-view.jsx b/front_minimal/src/sections/video-call/view/video-call-view.jsx
new file mode 100644
index 0000000..db2b139
--- /dev/null
+++ b/front_minimal/src/sections/video-call/view/video-call-view.jsx
@@ -0,0 +1,710 @@
+'use client';
+
+import 'src/styles/livekit-theme.css';
+import 'src/styles/livekit-components.css';
+
+import { createPortal } from 'react-dom';
+import { isTrackReference } from '@livekit/components-core';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useRef, useState, useEffect, Component } from 'react';
+import { Track, RoomEvent, VideoPresets } from 'livekit-client';
+import {
+ useTracks,
+ LiveKitRoom,
+ useStartAudio,
+ useRoomContext,
+ VideoConference,
+ ParticipantTile,
+ RoomAudioRenderer,
+ ConnectionStateToast,
+} from '@livekit/components-react';
+
+import { getLesson } from 'src/utils/dashboard-api';
+import { getOrCreateLessonBoard } from 'src/utils/board-api';
+import { getLiveKitConfig, participantConnected } from 'src/utils/livekit-api';
+
+import { ExitLessonModal } from 'src/sections/video-call/livekit/exit-lesson-modal';
+
+import { useAuthContext } from 'src/auth/hooks';
+
+// ----------------------------------------------------------------------
+
+const PRESET_2K = {
+ resolution: { width: 2560, height: 1440 },
+ encoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
+};
+
+const CHAT_PANEL_WIDTH = 420;
+
+const LS_AUDIO_PLAYBACK_ALLOWED = 'videoConference_audioPlaybackAllowed';
+const LS_AUDIO_ENABLED = 'videoConference_audioEnabled';
+const LS_VIDEO_ENABLED = 'videoConference_videoEnabled';
+const SS_PREJOIN_DONE = 'livekit_prejoin_done';
+
+// ----------------------------------------------------------------------
+
+function isLiveKitLayoutError(error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ return (
+ msg.includes('Element not part of the array') ||
+ msg.includes('updatePages') ||
+ msg.includes('_placeholder not in')
+ );
+}
+
+class LiveKitLayoutErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { error: null, recoverKey: 0 };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+
+ componentDidCatch(error) {
+ if (isLiveKitLayoutError(error)) {
+ window.setTimeout(() => {
+ this.setState((s) => ({ error: null, recoverKey: s.recoverKey + 1 }));
+ }, 100);
+ }
+ }
+
+ render() {
+ const { error, recoverKey } = this.state;
+ const { children } = this.props;
+ if (error && !isLiveKitLayoutError(error)) throw error;
+ if (error) return ;
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ return <>{children}>;
+ }
+}
+
+// ----------------------------------------------------------------------
+
+function getSavedAudioVideo() {
+ try {
+ const rawA = localStorage.getItem(LS_AUDIO_ENABLED);
+ const rawV = localStorage.getItem(LS_VIDEO_ENABLED);
+ return { audio: rawA !== 'false', video: rawV !== 'false' };
+ } catch {
+ return { audio: true, video: true };
+ }
+}
+
+function getInitialShowPreJoin(sp) {
+ try {
+ if (typeof window !== 'undefined' && sessionStorage.getItem(SS_PREJOIN_DONE) === '1') return false;
+ return sp.get('skip_prejoin') !== '1';
+ } catch {
+ return true;
+ }
+}
+
+function getTokenMetadata(token) {
+ if (!token) return {};
+ try {
+ const parts = token.split('.');
+ if (parts.length !== 3) return {};
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
+ const { metadata } = payload;
+ if (!metadata || typeof metadata !== 'string') return {};
+ const parsed = JSON.parse(metadata);
+ return {
+ board_id: parsed?.board_id ?? undefined,
+ is_mentor: parsed?.is_mentor === true,
+ };
+ } catch {
+ return {};
+ }
+}
+
+// ----------------------------------------------------------------------
+
+function StartAudioOverlay() {
+ const room = useRoomContext();
+ const { mergedProps, canPlayAudio } = useStartAudio({ room, props: {} });
+ const [dismissed, setDismissed] = useState(() => {
+ try {
+ return localStorage.getItem(LS_AUDIO_PLAYBACK_ALLOWED) === 'true';
+ } catch {
+ return false;
+ }
+ });
+
+ useEffect(() => {
+ if (canPlayAudio) {
+ try {
+ localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
+ } catch { /* ignore */ }
+ }
+ }, [canPlayAudio]);
+
+ if (canPlayAudio || dismissed) return null;
+
+ const handleClick = () => {
+ try {
+ localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
+ } catch { /* ignore */ }
+ setDismissed(true);
+ mergedProps.onClick?.();
+ };
+
+ return (
+
+
Чтобы слышать собеседника, разрешите воспроизведение звука
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function RemoteParticipantPiP({ chatOpen }) {
+ const tracks = useTracks([Track.Source.Camera, Track.Source.ScreenShare], { onlySubscribed: true });
+ const remoteRef = tracks.find((ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal);
+ if (!remoteRef || !isTrackReference(remoteRef)) return null;
+ return (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function WhiteboardIframe({ boardId, showingBoard }) {
+ const excalidrawUrl = process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '';
+ const iframeRef = useRef(null);
+
+ useEffect(() => {
+ if (!excalidrawUrl || !boardId) return undefined;
+ const container = iframeRef.current;
+ if (!container) return undefined;
+ const iframe = document.createElement('iframe');
+ iframe.src = `${excalidrawUrl}?boardId=${boardId}`;
+ iframe.style.cssText = 'width:100%;height:100%;border:none;';
+ iframe.allow = 'camera; microphone; fullscreen';
+ container.innerHTML = '';
+ container.appendChild(iframe);
+ return () => {
+ container.innerHTML = '';
+ };
+ }, [boardId, excalidrawUrl]);
+
+ if (!excalidrawUrl) {
+ return (
+
+ Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function PreJoinScreen({ onJoin, onCancel }) {
+ const [audioEnabled, setAudioEnabled] = useState(true);
+ const [videoEnabled, setVideoEnabled] = useState(true);
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const saved = getSavedAudioVideo();
+ setAudioEnabled(saved.audio);
+ setVideoEnabled(saved.video);
+ }, []);
+
+ useEffect(() => {
+ if (!videoEnabled) return undefined;
+ let stream = null;
+ navigator.mediaDevices
+ .getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false })
+ .then((s) => {
+ stream = s;
+ if (videoRef.current) videoRef.current.srcObject = s;
+ })
+ .catch(() => {});
+ return () => {
+ stream?.getTracks().forEach((t) => t.stop());
+ };
+ }, [videoEnabled]);
+
+ const handleJoin = () => {
+ try {
+ localStorage.setItem(LS_AUDIO_ENABLED, String(audioEnabled));
+ localStorage.setItem(LS_VIDEO_ENABLED, String(videoEnabled));
+ } catch { /* ignore */ }
+ onJoin(audioEnabled, videoEnabled);
+ };
+
+ return (
+
+
+
+
Настройки перед входом
+
Настройте камеру и микрофон
+
+
+
+ {videoEnabled ? (
+
+ ) : (
+
+ )}
+
+
+ {[
+ { label: 'Микрофон', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) },
+ { label: 'Камера', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) },
+ ].map(({ label, enabled, toggle }) => (
+
+ {label}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard }) {
+ const room = useRoomContext();
+ const router = useRouter();
+ const { user } = useAuthContext();
+ const [showPlatformChat, setShowPlatformChat] = useState(false);
+ const [showExitModal, setShowExitModal] = useState(false);
+ const [showNavMenu, setShowNavMenu] = useState(false);
+
+ useEffect(() => {
+ const onConnected = () => {
+ if (room.name || lessonId) {
+ participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
+ }
+ };
+ room.on(RoomEvent.Connected, onConnected);
+ if (room.state === 'connected' && (room.name || lessonId)) {
+ participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
+ }
+ return () => {
+ room.off(RoomEvent.Connected, onConnected);
+ };
+ }, [room, lessonId]);
+
+ // Inject exit and burger buttons into LiveKit control bar
+ useEffect(() => {
+ if (showBoard) return undefined;
+ const id = setTimeout(() => {
+ const bar = document.querySelector('.lk-control-bar');
+ if (!bar) return;
+ if (!bar.querySelector('.lk-burger-button')) {
+ const burger = document.createElement('button');
+ burger.type = 'button';
+ burger.className = 'lk-button lk-burger-button';
+ burger.title = 'Меню';
+ burger.textContent = '☰';
+ burger.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-burger-click')));
+ bar.insertBefore(burger, bar.firstChild);
+ }
+ if (!bar.querySelector('.lk-custom-exit-button')) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'lk-button lk-custom-exit-button';
+ btn.title = 'Выйти';
+ btn.textContent = '🚪';
+ btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click')));
+ bar.appendChild(btn);
+ }
+ }, 800);
+ return () => clearTimeout(id);
+ }, [showBoard]);
+
+ useEffect(() => {
+ const handler = () => setShowNavMenu((v) => !v);
+ window.addEventListener('livekit-burger-click', handler);
+ return () => window.removeEventListener('livekit-burger-click', handler);
+ }, []);
+
+ useEffect(() => {
+ const handler = () => {
+ if (user?.role === 'mentor') {
+ setShowExitModal(true);
+ } else {
+ room.disconnect();
+ }
+ };
+ window.addEventListener('livekit-exit-click', handler);
+ return () => window.removeEventListener('livekit-exit-click', handler);
+ }, [user?.role, room]);
+
+ // Save audio/video state on track events
+ useEffect(() => {
+ const lp = room.localParticipant;
+ if (!lp) return undefined;
+ const save = () => {
+ try {
+ localStorage.setItem(LS_AUDIO_ENABLED, String(lp.isMicrophoneEnabled));
+ localStorage.setItem(LS_VIDEO_ENABLED, String(lp.isCameraEnabled));
+ } catch { /* ignore */ }
+ };
+ const onMuted = (pub, participant) => {
+ if (participant?.sid !== lp.sid) return;
+ if (pub?.source === Track.Source.Microphone) {
+ try { localStorage.setItem(LS_AUDIO_ENABLED, 'false'); } catch { /* ignore */ }
+ } else if (pub?.source === Track.Source.Camera) {
+ try { localStorage.setItem(LS_VIDEO_ENABLED, 'false'); } catch { /* ignore */ }
+ } else {
+ save();
+ }
+ };
+ const onUnmuted = (pub, participant) => {
+ if (participant?.sid !== lp.sid) return;
+ if (pub?.source === Track.Source.Microphone) {
+ try { localStorage.setItem(LS_AUDIO_ENABLED, 'true'); } catch { /* ignore */ }
+ } else if (pub?.source === Track.Source.Camera) {
+ try { localStorage.setItem(LS_VIDEO_ENABLED, 'true'); } catch { /* ignore */ }
+ } else {
+ save();
+ }
+ };
+ room.on(RoomEvent.TrackMuted, onMuted);
+ room.on(RoomEvent.TrackUnmuted, onUnmuted);
+ room.on(RoomEvent.LocalTrackPublished, save);
+ save();
+ return () => {
+ room.off(RoomEvent.TrackMuted, onMuted);
+ room.off(RoomEvent.TrackUnmuted, onUnmuted);
+ room.off(RoomEvent.LocalTrackPublished, save);
+ };
+ }, [room]);
+
+ const sidebarStyle = {
+ position: 'fixed',
+ right: showPlatformChat ? CHAT_PANEL_WIDTH + 16 : 16,
+ top: '50%',
+ transform: 'translateY(-50%)',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 8,
+ padding: 8,
+ background: 'rgba(0,0,0,0.7)',
+ borderRadius: 12,
+ zIndex: 10001,
+ };
+
+ const iconBtn = (active, disabled, title, icon, onClick) => (
+
+ );
+
+ return (
+
+
+
+
+ {typeof document !== 'undefined' &&
+ createPortal(
+ <>
+ {showBoard &&
}
+ {showBoard && (
+
+ )}
+ {showNavMenu && (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+
setShowNavMenu(false)}
+ onKeyDown={(e) => e.key === 'Escape' && setShowNavMenu(false)}
+ style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
+ >
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 16, padding: 24, minWidth: 240 }}>
+
+
+
+
+ )}
+
+ {iconBtn(!showBoard, false, 'Камера', '📹', () => setShowBoard(false))}
+ {iconBtn(showBoard, !boardId || boardLoading, boardLoading ? 'Загрузка доски...' : !boardId ? 'Доска недоступна' : 'Доска', boardLoading ? '⏳' : '🎨', () => boardId && !boardLoading && setShowBoard(true))}
+ {lessonId != null && iconBtn(showPlatformChat, false, 'Чат', '💬', () => setShowPlatformChat((v) => !v))}
+
+ >,
+ document.body
+ )}
+
+
setShowExitModal(false)}
+ onExit={() => room.disconnect()}
+ />
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function VideoCallView() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const accessToken = searchParams.get('token');
+ const lessonIdParam = searchParams.get('lesson_id');
+ const { user } = useAuthContext();
+
+ const [serverUrl, setServerUrl] = useState('');
+ const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
+ const [audioEnabled, setAudioEnabled] = useState(true);
+ const [videoEnabled, setVideoEnabled] = useState(true);
+ const [avReady, setAvReady] = useState(false);
+ const [lessonCompleted, setLessonCompleted] = useState(false);
+ const [effectiveLessonId, setEffectiveLessonId] = useState(null);
+ const [boardId, setBoardId] = useState(null);
+ const [boardLoading, setBoardLoading] = useState(false);
+ const [showBoard, setShowBoard] = useState(false);
+ const boardPollRef = useRef(null);
+
+ // Load audio/video preferences from localStorage after mount
+ useEffect(() => {
+ const saved = getSavedAudioVideo();
+ setAudioEnabled(saved.audio);
+ setVideoEnabled(saved.video);
+ setAvReady(true);
+ }, []);
+
+ // Determine effective lesson ID
+ useEffect(() => {
+ const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
+ if (id && !Number.isNaN(id)) {
+ setEffectiveLessonId(id);
+ try {
+ sessionStorage.setItem('livekit_lesson_id', String(id));
+ } catch { /* ignore */ }
+ } else {
+ try {
+ const stored = sessionStorage.getItem('livekit_lesson_id');
+ if (stored) setEffectiveLessonId(parseInt(stored, 10));
+ } catch { /* ignore */ }
+ }
+ }, [lessonIdParam]);
+
+ // Load server URL + check lesson status
+ useEffect(() => {
+ const load = async () => {
+ if (lessonIdParam) {
+ try {
+ const l = await getLesson(lessonIdParam);
+ if (l.status === 'completed') {
+ const now = new Date();
+ const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time);
+ if (now > new Date(end.getTime() + 10 * 60000)) setLessonCompleted(true);
+ }
+ } catch { /* ignore */ }
+ }
+ try {
+ const config = await getLiveKitConfig();
+ setServerUrl(config.server_url || 'ws://127.0.0.1:7880');
+ } catch { /* ignore */ }
+ };
+ load();
+ }, [lessonIdParam]);
+
+ // Board: from token metadata or poll API
+ useEffect(() => {
+ const meta = getTokenMetadata(accessToken);
+ if (meta.board_id) {
+ setBoardId(meta.board_id);
+ setBoardLoading(false);
+ return undefined;
+ }
+ if (!effectiveLessonId) {
+ setBoardLoading(false);
+ return undefined;
+ }
+ let cancelled = false;
+ const stopPoll = () => {
+ if (boardPollRef.current) {
+ clearInterval(boardPollRef.current);
+ boardPollRef.current = null;
+ }
+ };
+ setBoardLoading(true);
+ getOrCreateLessonBoard(effectiveLessonId)
+ .then((b) => {
+ if (!cancelled) {
+ setBoardId(b.board_id);
+ stopPoll();
+ }
+ })
+ .catch(() => {})
+ .finally(() => {
+ if (!cancelled) setBoardLoading(false);
+ });
+ boardPollRef.current = setInterval(() => {
+ if (cancelled) return;
+ getOrCreateLessonBoard(effectiveLessonId)
+ .then((b) => {
+ if (!cancelled) {
+ setBoardId(b.board_id);
+ stopPoll();
+ }
+ })
+ .catch(() => {});
+ }, 10_000);
+ return () => {
+ cancelled = true;
+ stopPoll();
+ };
+ }, [accessToken, effectiveLessonId]);
+
+ if (lessonCompleted) {
+ return (
+
+
+
Урок завершён. Видеоконференция недоступна.
+
+
+
+ );
+ }
+
+ if (!accessToken || !serverUrl) {
+ return (
+
+ );
+ }
+
+ if (showPreJoin) {
+ return (
+ {
+ try {
+ sessionStorage.setItem(SS_PREJOIN_DONE, '1');
+ } catch { /* ignore */ }
+ setAudioEnabled(audio);
+ setVideoEnabled(video);
+ setShowPreJoin(false);
+ }}
+ onCancel={() => router.push('/dashboard')}
+ />
+ );
+ }
+
+ if (!avReady) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {boardId && (
+
+
+
+ )}
+ router.push('/dashboard')}
+ style={{ height: '100vh' }}
+ data-lk-theme="default"
+ options={{
+ adaptiveStream: true,
+ dynacast: true,
+ videoCaptureDefaults: { resolution: PRESET_2K.resolution, frameRate: 30 },
+ publishDefaults: {
+ simulcast: true,
+ videoEncoding: PRESET_2K.encoding,
+ videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
+ screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
+ screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
+ degradationPreference: 'maintain-resolution',
+ },
+ audioCaptureDefaults: { noiseSuppression: true, echoCancellation: true },
+ }}
+ >
+
+
+
+
+ >
+ );
+}
diff --git a/front_minimal/src/styles/livekit-components.css b/front_minimal/src/styles/livekit-components.css
new file mode 100644
index 0000000..b153498
--- /dev/null
+++ b/front_minimal/src/styles/livekit-components.css
@@ -0,0 +1,6 @@
+/**
+ * Стили компонентов LiveKit — локальная копия на сервере.
+ * Скопировано из @livekit/components-styles (dist/general/index.css).
+ * Обновить при обновлении пакета: cp node_modules/@livekit/components-styles/dist/general/index.css front_material/styles/livekit-components.css
+ */
+[data-lk-theme]{font-size:var(--lk-font-size);font-family:var(--lk-font-family);color:var(--lk-fg)}[data-lk-theme] .lk-list{list-style:none;margin:0;padding:0}[data-lk-theme] .lk-form-control{font-family:var(--lk-font-family);padding:.625rem 1rem;background-color:var(--lk-control-bg);border:1px solid var(--lk-border-color);border-radius:var(--lk-border-radius)}[data-lk-theme=default]{color-scheme:dark;--lk-bg: #111;--lk-bg2: rgb(29.75, 29.75, 29.75);--lk-bg3: rgb(42.5, 42.5, 42.5);--lk-bg4: rgb(55.25, 55.25, 55.25);--lk-bg5: #444444;--lk-fg: #fff;--lk-fg2: rgb(244.8, 244.8, 244.8);--lk-fg3: rgb(234.6, 234.6, 234.6);--lk-fg4: rgb(224.4, 224.4, 224.4);--lk-fg5: rgb(214.2, 214.2, 214.2);--lk-border-color: rgba(255, 255, 255, 0.1);--lk-accent-fg: #fff;--lk-accent-bg: #1f8cf9;--lk-accent2: rgb(50.867826087, 150.2, 249.532173913);--lk-accent3: rgb(70.7356521739, 160.4, 250.0643478261);--lk-accent4: rgb(90.6034782609, 170.6, 250.5965217391);--lk-danger-fg: #fff;--lk-danger: #f91f31;--lk-danger2: rgb(249.532173913, 50.867826087, 67.2713043478);--lk-danger3: rgb(250.0643478261, 70.7356521739, 85.5426086957);--lk-danger4: rgb(250.5965217391, 90.6034782609, 103.8139130435);--lk-success-fg: #fff;--lk-success: #1ff968;--lk-success2: rgb(50.867826087, 249.532173913, 117.3930434783);--lk-success3: rgb(70.7356521739, 250.0643478261, 130.7860869565);--lk-success4: rgb(90.6034782609, 250.5965217391, 144.1791304348);--lk-control-fg: var(--lk-fg);--lk-control-bg: var(--lk-bg2);--lk-control-hover-bg: var(--lk-bg3);--lk-control-active-bg: var(--lk-bg4);--lk-control-active-hover-bg: var(--lk-bg5);--lk-connection-excellent: #06db4d;--lk-connection-good: #f9b11f;--lk-connection-poor: #f91f31;--lk-font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";--lk-font-size: 16px;--lk-line-height: 1.5;--lk-border-radius: 0.5rem;--lk-box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.15);--lk-drop-shadow: rgba(255, 255, 255, 0.2) 0px 0px 24px;--lk-grid-gap: 0.5rem;--lk-control-bar-height: 69px;--lk-chat-header-height: 69px}.lk-button,.lk-start-audio-button,.lk-chat-toggle,.lk-disconnect-button{position:relative;display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.625rem 1rem;color:var(--lk-control-fg);background-image:none;background-color:var(--lk-control-bg);border:0;border-radius:var(--lk-border-radius);cursor:pointer;white-space:nowrap;font-size:inherit;line-height:inherit;user-select:none}.lk-button:not(:disabled):hover,.lk-start-audio-button:not(:disabled):hover,.lk-chat-toggle:not(:disabled):hover,.lk-disconnect-button:not(:disabled):hover{background-color:var(--lk-control-hover-bg)}.lk-button>svg,.lk-start-audio-button>svg,.lk-chat-toggle>svg,.lk-disconnect-button>svg{overflow:visible}.lk-button[aria-pressed=true],[aria-pressed=true].lk-start-audio-button,[aria-pressed=true].lk-chat-toggle,[aria-pressed=true].lk-disconnect-button{background-color:var(--lk-control-active-bg)}.lk-button[aria-pressed=true]:hover,[aria-pressed=true].lk-start-audio-button:hover,[aria-pressed=true].lk-chat-toggle:hover,[aria-pressed=true].lk-disconnect-button:hover{background-color:var(--lk-control-active-hover-bg)}.lk-button[data-lk-source=screen_share][data-lk-enabled=true],[data-lk-source=screen_share][data-lk-enabled=true].lk-start-audio-button,[data-lk-source=screen_share][data-lk-enabled=true].lk-chat-toggle,[data-lk-source=screen_share][data-lk-enabled=true].lk-disconnect-button{background-color:var(--lk-accent-bg)}.lk-button[data-lk-source=screen_share][data-lk-enabled=true]:hover,[data-lk-source=screen_share][data-lk-enabled=true].lk-start-audio-button:hover,[data-lk-source=screen_share][data-lk-enabled=true].lk-chat-toggle:hover,[data-lk-source=screen_share][data-lk-enabled=true].lk-disconnect-button:hover{background-color:var(--lk-accent2)}.lk-button:disabled,.lk-start-audio-button:disabled,.lk-chat-toggle:disabled,.lk-disconnect-button:disabled{opacity:.5}.lk-button-group{display:inline-flex;align-items:stretch;height:100%}.lk-button-group>.lk-button:first-child,.lk-button-group>.lk-start-audio-button:first-child,.lk-button-group>.lk-chat-toggle:first-child,.lk-button-group>.lk-disconnect-button:first-child{border-top-right-radius:0;border-bottom-right-radius:0}.lk-button-group-menu{position:relative;flex-shrink:0}.lk-button-group-menu>.lk-button,.lk-button-group-menu>.lk-start-audio-button,.lk-button-group-menu>.lk-chat-toggle,.lk-button-group-menu>.lk-disconnect-button{height:100%;border-top-left-radius:0;border-bottom-left-radius:0}.lk-button-group-menu>.lk-button::after,.lk-button-group-menu>.lk-start-audio-button::after,.lk-button-group-menu>.lk-chat-toggle::after,.lk-button-group-menu>.lk-disconnect-button::after{margin-left:0}.lk-button-menu::after{display:inline-block;content:"";width:.5em;height:.5em;margin-top:-0.25rem;margin-left:.5rem;border-left:.125em solid;border-bottom:.125em solid;transform:rotate(-45deg);transform-origin:center center}.lk-disconnect-button{font-weight:600;color:var(--lk-danger);border:1px solid var(--lk-danger)}.lk-disconnect-button:not(:disabled):hover{--lk-control-hover-bg: var(--lk-danger2);color:var(--lk-danger-fg)}.lk-disconnect-button:not(:disabled):active{--lk-control-hover-bg: var(--lk-danger3);color:var(--lk-danger-fg)}.lk-chat-toggle{position:relative}.lk-chat-toggle[data-lk-unread-msgs]:not([data-lk-unread-msgs="0"]):after{content:attr(data-lk-unread-msgs);position:absolute;top:0;left:0;padding:.25rem;margin-left:.25rem;margin-top:.25rem;border-radius:50%;font-size:.5rem;line-height:.75;background:var(--lk-accent-bg)}.lk-media-device-select:not(:last-child){padding-bottom:.5rem;margin-bottom:.75rem;border-bottom:1px solid var(--lk-border-color)}.lk-media-device-select li:not(:last-child){margin-bottom:.25rem}.lk-media-device-select li>.lk-button{width:100%;justify-content:start;padding-block:.5rem}.lk-media-device-select li:not([data-lk-active=true])>.lk-button:not(:disabled):hover{background-color:var(--lk-bg3)}.lk-media-device-select [data-lk-active=false]>.lk-button:hover{cursor:pointer;background-color:rgba(0,0,0,.05)}.lk-media-device-select [data-lk-active=true]>.lk-button{color:var(--lk-accent-fg);background-color:var(--lk-accent-bg)}.lk-device-menu{width:max-content;position:absolute;top:0;left:0;z-index:5;min-width:10rem;padding:.5rem;margin-bottom:.25rem;white-space:nowrap;background-color:var(--lk-bg2);border:1px solid var(--lk-border-color);border-radius:.75rem;box-shadow:var(--lk-box-shadow)}.lk-device-menu-heading{padding:.25rem .5rem;font-weight:bold;opacity:.65}.lk-start-audio-button{color:var(--lk-accent-fg);background-color:var(--lk-accent-bg)}@media screen and (max-width: 600px){.lk-start-audio-button{position:fixed;top:50%;left:50%;transform:translate(-50%, -50%)}}.lk-pagination-control{position:absolute;bottom:1rem;left:50%;transform:translateX(-50%);display:flex;align-items:stretch;background-color:var(--lk-control-bg);border-radius:var(--lk-border-radius);transition:opacity ease-in-out .15s;opacity:0}.lk-pagination-control:hover{opacity:1}.lk-pagination-control>.lk-button:first-child{border-top-right-radius:0;border-bottom-right-radius:0}.lk-pagination-control>.lk-button:first-child>svg{transform:rotate(180deg)}.lk-pagination-control>.lk-button:last-child{border-top-left-radius:0;border-bottom-left-radius:0}.lk-pagination-count{padding:.5rem .875rem;border-inline:1px solid var(--lk-bg)}[data-lk-user-interaction=true].lk-pagination-control{opacity:1}.lk-pagination-indicator{position:absolute;height:var(--lk-grid-gap);background-color:var(--lk-bg2);width:fit-content;padding:.2rem .5rem;bottom:calc(var(--lk-grid-gap)/2);left:50%;transform:translateX(-50%);border-radius:2rem;opacity:1;display:flex;gap:.2rem;align-items:center}.lk-pagination-indicator span{display:inline-block;width:.4rem;height:.4rem;border-radius:9999999px;background-color:var(--lk-fg);opacity:.35;transition:opacity linear .2s}.lk-pagination-indicator span[data-lk-active]{opacity:.9}.lk-grid-layout{--lk-col-count: 1;--lk-row-count: 1;display:grid;grid-template-columns:repeat(var(--lk-col-count), minmax(0, 1fr));grid-auto-rows:minmax(0, 1fr);grid-gap:var(--lk-grid-gap);width:100%;height:100%;max-width:100%;max-height:100%;padding:var(--lk-grid-gap)}.lk-grid-layout[data-lk-pagination=true]{padding-bottom:calc(var(--lk-grid-gap)*2)}.lk-focus-layout{display:grid;grid-template-columns:1fr 5fr;gap:var(--lk-grid-gap);width:100%;max-height:100%;padding:var(--lk-grid-gap)}.lk-focused-participant{position:relative}.lk-focused-participant .lk-pip-track{position:absolute;top:10px;right:10px;width:20%;height:auto}@media(max-width: 600px){.lk-focus-layout{grid-template-columns:1fr;grid-template-rows:5fr 1fr}.lk-carousel{order:1}}.lk-carousel{max-height:100%;display:flex;gap:var(--lk-grid-gap)}.lk-carousel>*{flex-shrink:0;aspect-ratio:16/10;scroll-snap-align:start}.lk-carousel[data-lk-orientation=vertical]{flex-direction:column;scroll-snap-type:y mandatory;overflow-y:auto;overflow-x:hidden}.lk-carousel[data-lk-orientation=vertical]>*{--lk-height-minus-gaps: calc(100% - calc(var(--lk-grid-gap) * calc(var(--lk-max-visible-tiles) - 1)));height:calc(var(--lk-height-minus-gaps)/var(--lk-max-visible-tiles))}.lk-carousel[data-lk-orientation=horizontal]{scroll-snap-type:x mandatory;overflow-y:hidden;overflow-x:auto}.lk-carousel[data-lk-orientation=horizontal]>*{--lk-width-minus-gaps: calc(100% - var(--lk-grid-gap) * (var(--lk-max-visible-tiles) - 1));width:calc(var(--lk-width-minus-gaps)/var(--lk-max-visible-tiles))}.lk-connection-quality{width:1.5rem;height:1.5rem}.lk-track-muted-indicator-camera,.lk-track-muted-indicator-microphone{position:relative;width:var(--lk-indicator-size, 1rem);height:var(--lk-indicator-size, 1rem);margin-inline-end:.25rem;transition:opacity .25s ease-in-out}.lk-track-muted-indicator-camera[data-lk-muted=true]{opacity:.5}.lk-track-muted-indicator-microphone{--lk-bg: var(--lk-icon-mic)}.lk-track-muted-indicator-microphone[data-lk-muted=true]{opacity:.5}.lk-participant-name{font-size:.875rem}.lk-participant-media-video{width:100%;height:100%;object-fit:cover;object-position:center;background-color:#000}.lk-participant-media-video[data-lk-orientation=landscape]{object-fit:cover}.lk-participant-media-video[data-lk-orientation=portrait],.lk-participant-media-video[data-lk-source=screen_share]{object-fit:contain;background-color:var(--lk-bg2)}.lk-participant-media-audio{width:auto}[data-lk-facing-mode=user] .lk-participant-media-video[data-lk-local-participant=true][data-lk-source=camera]{transform:rotateY(180deg)}.lk-audio-visualizer{width:100%;height:100%;min-height:160px;background:var(--lk-bg-control);aspect-ratio:16/9;border-radius:.5rem;display:flex;justify-content:space-around;align-items:center}.lk-audio-visualizer>rect{fill:var(--lk-accent-bg);transition:transform 100ms cubic-bezier(0.19, 0.02, 0.09, 1)}.lk-audio-visualizer>path{stroke:var(--lk-accent-bg);transition:100ms cubic-bezier(0.19, 0.02, 0.09, 1)}.lk-audio-bar-visualizer{display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:var(--lk-bg);gap:var(--lk-va-bar-gap, 24px)}.lk-audio-bar-visualizer>.lk-audio-bar{transform-origin:"center";height:100%;width:var(--lk-va-bar-width, 12px);border-radius:var(--lk-va-bar-border-radius, 32px);background-color:var(--lk-va-bar-bg, rgba(136, 136, 136, 0.2));transition:background-color .25s ease-out}.lk-audio-bar-visualizer[data-lk-va-state=speaking]>.lk-audio-bar,.lk-audio-bar-visualizer>.lk-audio-bar.lk-highlighted,.lk-audio-bar-visualizer>[data-lk-highlighted=true]{background-color:var(--lk-fg, rgb(136, 136, 136));transition:none}.lk-audio-bar-visualizer[data-lk-va-state=thinking]{transition:background-color .15s ease-out}.lk-participant-tile{--lk-speaking-indicator-width: 2.5px;position:relative;display:flex;flex-direction:column;gap:.375rem;overflow:hidden;border-radius:var(--lk-border-radius)}.lk-participant-tile::after{content:"";position:absolute;top:0;bottom:0;left:0;right:0;border-radius:var(--lk-border-radius);border:0px solid var(--lk-accent-bg);transition-property:border opacity;transition-delay:.5s;transition-duration:.4s;pointer-events:none}.lk-participant-tile[data-lk-speaking=true]:not([data-lk-source=screen_share])::after{transition-delay:0s;transition-duration:.2s;border-width:var(--lk-speaking-indicator-width)}.lk-participant-tile .lk-focus-toggle-button{position:absolute;top:.25rem;right:.25rem;padding:.25rem;background-color:rgba(0,0,0,.5);border-radius:calc(var(--lk-border-radius)/2);opacity:0;transition:opacity .2s ease-in-out;transition-delay:.2s}.lk-participant-tile:hover .lk-focus-toggle-button,.lk-participant-tile:focus .lk-focus-toggle-button{opacity:1;transition-delay:0}.lk-participant-tile .lk-connection-quality{opacity:0;transition:opacity .2s ease-in-out;transition-delay:.2s}.lk-participant-tile .lk-connection-quality[data-lk-quality=poor]{opacity:1;transition-delay:0}.lk-participant-tile:hover .lk-connection-quality,.lk-participant-tile:focus .lk-connection-quality{opacity:1;transition-delay:0}.lk-participant-tile .lk-participant-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background-color:var(--lk-bg2);opacity:0;transition:opacity .2s ease-in-out;pointer-events:none;border-radius:var(--lk-border-radius)}.lk-participant-tile .lk-participant-placeholder svg{height:100%;width:auto;padding:10%}.lk-participant-tile[data-lk-video-muted=true][data-lk-source=camera] .lk-participant-placeholder{opacity:1}.lk-participant-metadata{position:absolute;right:.25rem;bottom:.25rem;left:.25rem;display:flex;flex-direction:row;align-items:center;justify-content:space-between;gap:.5rem;line-height:1}.lk-participant-metadata-item{display:flex;align-items:center;padding:.25rem;background-color:rgba(0,0,0,.5);border-radius:calc(var(--lk-border-radius)/2)}.lk-toast{position:fixed;top:.75rem;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:.5rem;padding:.75rem 1.25rem;background-color:var(--lk-bg);border:1px solid var(--lk-border-color);border-radius:var(--lk-border-radius);box-shadow:var(--lk-box-shadow)}.lk-spinner{animation:lk-rotate 2s infinite linear}@keyframes lk-rotate{from{transform:rotate(0deg)}to{transform:rotate(359deg)}}.lk-room-container{background-color:var(--lk-bg);line-height:var(--lk-line-height)}[data-lk-theme]{font-size:var(--lk-font-size);font-family:var(--lk-font-family);color:var(--lk-fg)}[data-lk-theme] .lk-list{list-style:none;margin:0;padding:0}[data-lk-theme] .lk-form-control{font-family:var(--lk-font-family);padding:.625rem 1rem;background-color:var(--lk-control-bg);border:1px solid var(--lk-border-color);border-radius:var(--lk-border-radius)}.lk-room-container{position:relative;width:100%;height:100%;--lk-has-imported-styles: "true"}.lk-room-container *[class^=lk-],.lk-room-container *[class*=" lk-"]{box-sizing:border-box}.lk-audio-conference{position:relative;width:100%;height:100%}.lk-audio-conference-stage{width:100%;height:100%;display:grid;grid-template-columns:repeat(3, 1fr);gap:10px}.lk-chat{display:grid;grid-template-rows:var(--lk-chat-header-height) 1fr var(--lk-control-bar-height);width:clamp(200px,55ch,60ch);background-color:var(--lk-bg2);border-left:1px solid var(--lk-border-color);align-items:end}.lk-chat-header{height:var(--lk-chat-header-height);padding:.75rem;position:relative;display:flex;align-items:center;justify-content:center}.lk-chat-header .lk-close-button{position:absolute;right:0;transform:translateX(-50%);background-color:rgba(0,0,0,0)}.lk-chat-header .lk-close-button:hover{background-color:var(--lk-control-active-hover-bg)}.lk-chat-messages{display:flex;width:100%;max-height:100%;flex-direction:column;gap:.25rem;overflow:auto}.lk-chat-entry{display:flex;flex-direction:column;gap:.25rem;margin:0 .25rem}.lk-chat-entry .lk-meta-data{font-size:.75rem;color:var(--lk-fg5);white-space:nowrap;padding:0 .3rem;display:flex}.lk-chat-entry .lk-meta-data .lk-participant-name{margin-top:1rem}.lk-chat-entry .lk-meta-data .lk-timestamp{margin-left:auto;align-self:flex-end}.lk-chat-entry .lk-edit-button{background:none;float:right;margin:0;padding:0 .25rem;border-radius:0;font-size:12px}.lk-chat-entry .lk-message-body{display:inline-block;border-radius:15px;padding:.25rem .75rem;word-break:break-word;width:fit-content;max-width:calc(100% - 32px)}.lk-chat-entry[data-lk-message-origin=local] .lk-message-body{background-color:var(--lk-bg5)}.lk-chat-entry[data-lk-message-origin=remote] .lk-message-body{background-color:var(--lk-accent4)}.lk-chat-entry a{text-decoration:underline;color:inherit}.lk-chat-entry *{margin-block-start:.25em;margin-block-end:.25em}.lk-chat-entry:last-child{margin-bottom:.25rem}.lk-chat-form{display:flex;gap:.75rem;padding:.75rem;border-top:1px solid var(--lk-border-color);max-height:var(--lk-control-bar-height)}.lk-chat-form-input{font-size:inherit;line-height:inherit;width:100%}@media(max-width: 600px){.lk-chat{position:fixed;top:0;right:0;max-width:100%;bottom:var(--lk-control-bar-height)}}.lk-control-bar,.lk-agent-control-bar{display:flex;gap:.5rem;align-items:center;justify-content:center;padding:.75rem;border-top:1px solid var(--lk-border-color);max-height:var(--lk-control-bar-height)}.lk-agent-control-bar{height:var(--lk-control-bar-height);--lk-bg: transparent;--lk-va-bar-width: 2px;--lk-va-bar-gap: 4px;--lk-va-bar-border-radius: 1px}.lk-agent-control-bar .lk-audio-bar-visualizer .lk-audio-bar.lk-highlighted{filter:none}.lk-prejoin{background-color:var(--lk-bg);line-height:var(--lk-line-height)}[data-lk-theme]{font-size:var(--lk-font-size);font-family:var(--lk-font-family);color:var(--lk-fg)}[data-lk-theme] .lk-list{list-style:none;margin:0;padding:0}[data-lk-theme] .lk-form-control{font-family:var(--lk-font-family);padding:.625rem 1rem;background-color:var(--lk-control-bg);border:1px solid var(--lk-border-color);border-radius:var(--lk-border-radius)}.lk-prejoin{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;padding:1rem;gap:1rem;margin-inline:auto;background-color:var(--lk-bg);width:min(100%,480px);align-items:stretch}.lk-prejoin .lk-video-container{position:relative;width:100%;height:auto;aspect-ratio:16/10;background-color:#000;border-radius:var(--lk-border-radius);overflow:hidden}.lk-prejoin .lk-video-container video,.lk-prejoin .lk-video-container .lk-camera-off-note{display:block;width:100%;height:100%;object-fit:cover}.lk-prejoin .lk-video-container video[data-lk-facing-mode=user]{transform:rotateY(180deg)}.lk-prejoin .lk-video-container .lk-camera-off-note{position:absolute;top:0px;left:0px;width:100%;aspect-ratio:16/10;background-color:#000;display:grid;place-items:center}.lk-prejoin .lk-video-container .lk-camera-off-note>*{height:70%;max-width:100%}.lk-prejoin .lk-audio-container{display:none}.lk-prejoin .lk-audio-container audio{width:100%;height:auto}.lk-prejoin .lk-button-group-container{display:flex;flex-wrap:nowrap;gap:1rem}.lk-prejoin .lk-button-group-container>.lk-button-group{width:50%}.lk-prejoin .lk-button-group-container>.lk-button-group>.lk-button{justify-content:left}.lk-prejoin .lk-button-group-container>.lk-button-group>.lk-button:first-child{width:100%}@media(max-width: 400px){.lk-prejoin .lk-button-group-container{flex-wrap:wrap}.lk-prejoin .lk-button-group-container>.lk-button-group{width:100%}}.lk-prejoin .lk-username-container{display:flex;flex-direction:column;gap:1rem;width:100%;max-width:100%}.lk-prejoin .lk-join-button{--lk-control-fg: var(--lk-accent-fg);--lk-control-bg: var(--lk-accent-bg);--lk-control-hover-bg: var(--lk-accent2);--lk-control-active-bg: var(--lk-accent3);--lk-control-active-hover-bg: var(--lk-accent4);background-color:var(--lk-control-bg)}.lk-prejoin .lk-join-button:hover{background-color:var(--lk-control-hover-bg)}.lk-focus-layout-wrapper,.lk-grid-layout-wrapper{position:relative;display:flex;justify-content:center;width:100%;height:calc(100% - var(--lk-control-bar-height))}.lk-grid-layout-wrapper{flex-direction:column;align-items:center}.lk-focus-layout-wrapper{align-items:stretch}.lk-video-conference{position:relative;display:flex;align-items:stretch;height:100%}.lk-video-conference-inner{display:flex;flex-direction:column;align-items:stretch;width:100%}.lk-settings-menu-modal{position:fixed;top:50%;left:50%;transform:translate(-50%, -50%);background:var(--lk-bg);padding:1rem;border-radius:var(--lk-border-radius);display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:.75rem 1.25rem;background-color:var(--lk-bg);border:1px solid var(--lk-border-color);border-radius:var(--lk-border-radius);box-shadow:var(--lk-box-shadow);min-width:50vw;min-height:50vh;max-width:100%;max-height:100%;overflow-y:auto}
diff --git a/front_minimal/src/styles/livekit-theme.css b/front_minimal/src/styles/livekit-theme.css
new file mode 100644
index 0000000..334162f
--- /dev/null
+++ b/front_minimal/src/styles/livekit-theme.css
@@ -0,0 +1,429 @@
+/**
+ * Кастомизация LiveKit через CSS переменные.
+ * Все стили и скрипты LiveKit отдаются с нашего сервера (бандл + этот файл).
+ */
+
+@keyframes lk-spin {
+ to { transform: rotate(360deg); }
+}
+
+:root {
+ /* Цвета фона */
+ --lk-bg: #1a1a1a;
+ --lk-bg2: #2a2a2a;
+ --lk-bg3: #3a3a3a;
+
+ /* Цвета текста */
+ --lk-fg: #ffffff;
+ --lk-fg2: rgba(255, 255, 255, 0.7);
+
+ /* Основные цвета */
+ --lk-control-bg: var(--md-sys-color-primary);
+ --lk-control-hover-bg: var(--md-sys-color-primary-container);
+ --lk-button-bg: rgba(255, 255, 255, 0.15);
+ --lk-button-hover-bg: rgba(255, 255, 255, 0.25);
+
+ /* Границы */
+ --lk-border-color: rgba(255, 255, 255, 0.1);
+ --lk-border-radius: 12px;
+
+ /* Фокус */
+ --lk-focus-ring: var(--md-sys-color-primary);
+
+ /* Ошибки */
+ --lk-danger: var(--md-sys-color-error);
+
+ /* Размеры */
+ --lk-control-bar-height: 80px;
+ --lk-participant-tile-gap: 12px;
+}
+
+/* Панель управления — без ограничения по ширине */
+.lk-control-bar {
+ background: rgba(0, 0, 0, 0.8) !important;
+ backdrop-filter: blur(20px) !important;
+ border-radius: 16px !important;
+ padding: 12px 16px !important;
+ margin: 16px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
+ max-width: none !important;
+ width: auto !important;
+}
+
+.lk-control-bar .lk-button-group,
+.lk-control-bar .lk-button-group-menu {
+ max-width: none !important;
+ width: auto !important;
+}
+
+/* Кнопки управления — ширина по контенту, без жёсткого ограничения */
+.lk-control-bar .lk-button {
+ min-width: 48px !important;
+ width: auto !important;
+ height: 48px !important;
+ border-radius: 12px !important;
+ transition: all 0.2s ease !important;
+ padding-left: 12px !important;
+ padding-right: 12px !important;
+}
+
+/* Русские подписи: скрываем английский текст, показываем свой */
+.lk-control-bar .lk-button[data-lk-source="microphone"],
+.lk-control-bar .lk-button[data-lk-source="camera"],
+.lk-control-bar .lk-button[data-lk-source="screen_share"],
+.lk-control-bar .lk-chat-toggle,
+.lk-control-bar .lk-disconnect-button,
+.lk-control-bar .lk-start-audio-button {
+ font-size: 0 !important;
+}
+
+.lk-control-bar .lk-button[data-lk-source="microphone"] > svg,
+.lk-control-bar .lk-button[data-lk-source="camera"] > svg,
+.lk-control-bar .lk-button[data-lk-source="screen_share"] > svg,
+.lk-control-bar .lk-chat-toggle > svg,
+.lk-control-bar .lk-disconnect-button > svg {
+ width: 16px !important;
+ height: 16px !important;
+ flex-shrink: 0 !important;
+}
+
+.lk-control-bar .lk-button[data-lk-source="microphone"]::after {
+ content: "Микрофон";
+ font-size: 1rem;
+}
+
+.lk-control-bar .lk-button[data-lk-source="camera"]::after {
+ content: "Камера";
+ font-size: 1rem;
+}
+
+.lk-control-bar .lk-button[data-lk-source="screen_share"]::after {
+ content: "Поделиться экраном";
+ font-size: 1rem;
+}
+
+.lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after {
+ content: "Остановить демонстрацию";
+}
+
+.lk-control-bar .lk-chat-toggle::after {
+ content: "Чат";
+ font-size: 1rem;
+}
+
+/* Кнопка бургер слева от микрофона — в панели LiveKit */
+.lk-burger-button {
+ background: rgba(255, 255, 255, 0.15) !important;
+ color: #fff !important;
+}
+
+/* Скрываем стандартную кнопку «Выйти» — используем свою внутри панели (модалка: Выйти / Выйти и завершить занятие) */
+.lk-control-bar .lk-disconnect-button {
+ display: none !important;
+}
+.lk-control-bar .lk-disconnect-button::after {
+ content: "Выйти";
+ font-size: 1rem;
+}
+
+/* Наша кнопка «Выйти» — внутри панели, рядом с «Поделиться экраном» */
+.lk-control-bar .lk-custom-exit-button {
+ font-size: 0 !important;
+ background: var(--md-sys-color-error) !important;
+ color: #fff !important;
+ border: none;
+ cursor: pointer;
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+}
+.lk-control-bar .lk-custom-exit-button::after {
+ content: "Выйти";
+ font-size: 1rem;
+}
+.lk-control-bar .lk-custom-exit-button > .material-symbols-outlined {
+ color: #fff !important;
+}
+
+/* Скрываем кнопку «Начать видео» — у нас свой StartAudioOverlay */
+.lk-control-bar .lk-start-audio-button {
+ display: none !important;
+}
+
+/* Кнопки без текста (только иконка) — минимальный размер */
+.lk-button {
+ min-width: 48px !important;
+ width: auto !important;
+ height: 48px !important;
+ border-radius: 12px !important;
+ transition: all 0.2s ease !important;
+}
+
+.lk-button:hover {
+ transform: scale(1.05);
+}
+
+.lk-button:active {
+ transform: scale(0.95);
+}
+
+/* Активная кнопка */
+.lk-button[data-lk-enabled="true"] {
+ background: var(--md-sys-color-primary) !important;
+}
+
+/* Кнопка отключения — белые иконка и текст */
+.lk-disconnect-button {
+ background: var(--md-sys-color-error) !important;
+ color: #fff !important;
+}
+.lk-disconnect-button > svg {
+ color: #fff !important;
+ fill: currentColor;
+}
+
+/* Плитки участников */
+.lk-participant-tile {
+ border-radius: 12px !important;
+ overflow: hidden !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
+}
+
+/* Плейсхолдер без камеры: скрываем дефолтную SVG, показываем аватар из API */
+.lk-participant-tile .lk-participant-placeholder svg {
+ display: none !important;
+}
+
+/* Контейнер для аватара — нужен для container queries */
+.lk-participant-tile .lk-participant-placeholder {
+ container-type: size;
+}
+
+.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
+ /* Квадрат: меньшая сторона контейнера, максимум 400px */
+ --avatar-size: min(min(80cqw, 80cqh), 400px);
+ width: var(--avatar-size);
+ height: var(--avatar-size);
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ object-position: center;
+ border-radius: 50%;
+}
+
+/* Fallback для браузеров без container queries */
+@supports not (width: 1cqw) {
+ .lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
+ width: 200px;
+ height: 200px;
+ }
+}
+
+/* Имя участника — белый текст (Камера, PiP) */
+.lk-participant-name {
+ background: rgba(0, 0, 0, 0.7) !important;
+ backdrop-filter: blur(10px) !important;
+ border-radius: 8px !important;
+ padding: 6px 12px !important;
+ font-weight: 600 !important;
+ color: #fff !important;
+}
+
+/* Чат LiveKit скрыт — используем чат сервиса (платформы) */
+.lk-video-conference .lk-chat {
+ display: none !important;
+}
+
+.lk-control-bar .lk-chat-toggle {
+ display: none !important;
+}
+
+/* Стили чата платформы оставляем для других страниц */
+.lk-chat {
+ background: var(--md-sys-color-surface) !important;
+ border-left: 1px solid var(--md-sys-color-outline) !important;
+}
+
+.lk-chat-entry {
+ background: var(--md-sys-color-surface-container) !important;
+ border-radius: 12px !important;
+ padding: 12px !important;
+ margin-bottom: 12px !important;
+}
+
+/* Сетка участников */
+.lk-grid-layout {
+ gap: 12px !important;
+ padding: 12px !important;
+}
+
+/* Меню выбора устройств — без ограничения по ширине */
+.lk-device-menu,
+.lk-media-device-select {
+ max-width: none !important;
+ width: max-content !important;
+ min-width: 0 !important;
+}
+
+.lk-media-device-select {
+ background: rgba(0, 0, 0, 0.95) !important;
+ backdrop-filter: blur(20px) !important;
+ border-radius: 12px !important;
+ padding: 8px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
+ border: 1px solid rgba(255, 255, 255, 0.1) !important;
+}
+
+.lk-media-device-select button {
+ border-radius: 8px !important;
+ padding: 10px 14px !important;
+ transition: background 0.2s ease !important;
+ width: 100% !important;
+ min-width: 0 !important;
+ white-space: normal !important;
+ text-align: left !important;
+}
+
+.lk-media-device-select button:hover {
+ background: rgba(255, 255, 255, 0.1) !important;
+}
+
+.lk-media-device-select button[data-lk-active="true"] {
+ background: var(--md-sys-color-primary) !important;
+}
+
+/* Индикатор говорящего */
+.lk-participant-tile[data-lk-speaking="true"] {
+ box-shadow: 0 0 0 3px var(--md-sys-color-primary) !important;
+}
+
+/* Layout для 1-на-1: собеседник на весь экран, своя камера в углу */
+/* Карусель position:absolute выходит из flow — остаётся только основной контент. */
+/* Сетка 5fr 1fr: единственный grid-ребёнок (основное видео) получает 5fr (расширяется). */
+.lk-focus-layout {
+ position: relative !important;
+ grid-template-columns: 5fr 1fr !important;
+}
+
+/* Основное видео (собеседник) на весь экран */
+.lk-focus-layout .lk-focus-layout-wrapper {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile {
+ width: 100% !important;
+ height: 100% !important;
+ border-radius: 0 !important;
+}
+
+/* Демонстрация экрана — на весь экран только в режиме фокуса (после клика на раскрытие) */
+/* Структура: .lk-focus-layout-wrapper > .lk-focus-layout > .lk-participant-tile */
+.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
+ position: absolute !important;
+ width: 100% !important;
+ height: 100% !important;
+ top: 0 !important;
+ left: 0 !important;
+ border-radius: 0 !important;
+ z-index: 50 !important;
+}
+
+/* Карусель с локальным видео (своя камера) */
+.lk-focus-layout .lk-carousel {
+ position: absolute !important;
+ bottom: 80px !important;
+ right: 16px !important;
+ width: 280px !important;
+ height: auto !important;
+ z-index: 100 !important;
+ pointer-events: auto !important;
+}
+
+.lk-focus-layout .lk-carousel .lk-participant-tile {
+ width: 280px !important;
+ height: 158px !important;
+ border-radius: 12px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
+ border: 2px solid rgba(255, 255, 255, 0.2) !important;
+}
+
+/* Скрыть стрелки карусели (они не нужны для 1 участника) */
+.lk-focus-layout .lk-carousel button[aria-label*="Previous"],
+.lk-focus-layout .lk-carousel button[aria-label*="Next"] {
+ display: none !important;
+}
+
+/* Если используется grid layout (фоллбэк) */
+.lk-grid-layout {
+ position: relative !important;
+}
+
+/* Для 2 участников: первый на весь экран, второй в углу */
+.lk-grid-layout[data-lk-participants="2"] {
+ display: block !important;
+ position: relative !important;
+}
+
+.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child {
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+ border-radius: 0 !important;
+}
+
+.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
+ position: absolute !important;
+ bottom: 80px !important;
+ right: 16px !important;
+ width: 280px !important;
+ height: 158px !important;
+ border-radius: 12px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
+ border: 2px solid rgba(255, 255, 255, 0.2) !important;
+ z-index: 100 !important;
+}
+
+/* Адаптивность */
+@media (max-width: 768px) {
+ .lk-control-bar {
+ border-radius: 12px !important;
+ padding: 8px 12px !important;
+ }
+
+ .lk-control-bar .lk-button,
+ .lk-button {
+ min-width: 44px !important;
+ width: auto !important;
+ height: 44px !important;
+ }
+
+ /* Уменьшаем размер локального видео на мобильных */
+ .lk-focus-layout .lk-carousel,
+ .lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
+ width: 160px !important;
+ height: 90px !important;
+ bottom: 70px !important;
+ right: 12px !important;
+ }
+}
+
+/* Качество отображения видео в контейнере LiveKit */
+.lk-participant-media-video {
+ background: #000 !important;
+}
+/* Демонстрация экрана: contain чтобы не обрезать, чёткое отображение */
+.lk-participant-media-video[data-lk-source="screen_share"] {
+ object-fit: contain !important;
+ object-position: center !important;
+ image-rendering: -webkit-optimize-contrast;
+ image-rendering: crisp-edges;
+}
+/* Сетка: минимальная высота плиток для крупного видео */
+.lk-grid-layout {
+ min-height: 0;
+}
+.lk-grid-layout .lk-participant-tile {
+ min-height: 240px;
+}
diff --git a/front_minimal/src/utils/analytics-api.js b/front_minimal/src/utils/analytics-api.js
new file mode 100644
index 0000000..183001f
--- /dev/null
+++ b/front_minimal/src/utils/analytics-api.js
@@ -0,0 +1,59 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+/**
+ * Последние 30 дней (диапазон по умолчанию)
+ */
+export function getLast30DaysRange() {
+ const now = new Date();
+ const end = new Date(now);
+ const start = new Date(now);
+ start.setDate(start.getDate() - 29);
+ const fmt = (d) => d.toISOString().slice(0, 10);
+ return { start_date: fmt(start), end_date: fmt(end) };
+}
+
+function buildParams(range) {
+ const p = new URLSearchParams();
+ p.set('period', range.period || 'custom');
+ p.set('start_date', range.start_date);
+ p.set('end_date', range.end_date);
+ return p.toString();
+}
+
+/**
+ * GET /analytics/overview?period=custom&start_date=&end_date=
+ */
+export async function getAnalyticsOverview(range) {
+ const q = buildParams(range);
+ const res = await axios.get(`/analytics/overview?${q}`);
+ return res.data;
+}
+
+/**
+ * GET /analytics/students?...
+ */
+export async function getAnalyticsStudents(range) {
+ const q = buildParams(range);
+ const res = await axios.get(`/analytics/students?${q}`);
+ return res.data;
+}
+
+/**
+ * GET /analytics/revenue?...
+ */
+export async function getAnalyticsRevenue(range) {
+ const q = buildParams(range);
+ const res = await axios.get(`/analytics/revenue?${q}`);
+ return res.data;
+}
+
+/**
+ * GET /analytics/grades_by_day?...
+ */
+export async function getAnalyticsGradesByDay(range) {
+ const q = buildParams(range);
+ const res = await axios.get(`/analytics/grades_by_day?${q}`);
+ return res.data;
+}
diff --git a/front_minimal/src/utils/axios.js b/front_minimal/src/utils/axios.js
index bb8b943..aa7f1d3 100644
--- a/front_minimal/src/utils/axios.js
+++ b/front_minimal/src/utils/axios.js
@@ -8,7 +8,7 @@ const axiosInstance = axios.create({ baseURL: CONFIG.site.serverUrl });
axiosInstance.interceptors.response.use(
(response) => response,
- (error) => Promise.reject((error.response && error.response.data) || 'Something went wrong!')
+ (error) => Promise.reject(error)
);
export default axiosInstance;
@@ -31,13 +31,17 @@ export const fetcher = async (args) => {
// ----------------------------------------------------------------------
export const endpoints = {
- chat: '/api/chat',
- kanban: '/api/kanban',
- calendar: '/api/calendar',
+ chat: '/chat',
+ kanban: '/kanban',
+ calendar: '/calendar',
auth: {
me: '/profile/me/',
signIn: '/auth/login/',
signUp: '/auth/register/',
+ refresh: '/auth/token/refresh/',
+ passwordReset: '/auth/password-reset/',
+ passwordResetConfirm: '/auth/password-reset-confirm/',
+ verifyEmail: '/auth/verify-email/',
},
mail: {
list: '/api/mail/list',
diff --git a/front_minimal/src/utils/board-api.js b/front_minimal/src/utils/board-api.js
new file mode 100644
index 0000000..0b96b9a
--- /dev/null
+++ b/front_minimal/src/utils/board-api.js
@@ -0,0 +1,54 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+/**
+ * GET /board/boards/my_boards/ — доски текущего пользователя
+ */
+export async function getMyBoards() {
+ const res = await axios.get('/board/boards/my_boards/');
+ return res.data;
+}
+
+/**
+ * GET /board/boards/shared_with_me/ — доски, к которым есть доступ
+ */
+export async function getSharedBoards() {
+ const res = await axios.get('/board/boards/shared_with_me/');
+ return res.data;
+}
+
+/**
+ * GET /board/boards/get-or-create-mentor-student/?mentor=X&student=Y
+ */
+export async function getOrCreateMentorStudentBoard(mentorId, studentId) {
+ const res = await axios.get(`/board/boards/get-or-create-mentor-student/?mentor=${mentorId}&student=${studentId}`);
+ return res.data;
+}
+
+/**
+ * GET or create board for a lesson.
+ * Resolves lesson → mentor/student IDs → getOrCreateMentorStudentBoard
+ */
+export async function getOrCreateLessonBoard(lessonId) {
+ const lessonRes = await axios.get(`/schedule/lessons/${lessonId}/`);
+ const lesson = lessonRes.data;
+ const mentorId = typeof lesson.mentor === 'object' ? lesson.mentor?.id : lesson.mentor;
+ const client = lesson.client;
+ let studentId;
+ if (client && typeof client === 'object') {
+ studentId = client.user?.id ?? client.id;
+ } else {
+ studentId = client;
+ }
+ if (!mentorId || !studentId) throw new Error('Не удалось определить ментора и студента из занятия');
+ return getOrCreateMentorStudentBoard(mentorId, studentId);
+}
+
+/**
+ * POST /board/boards/ — создать новую доску
+ */
+export async function createBoard(data) {
+ const res = await axios.post('/board/boards/', data);
+ return res.data;
+}
diff --git a/front_minimal/src/utils/chat-api.js b/front_minimal/src/utils/chat-api.js
new file mode 100644
index 0000000..d33e727
--- /dev/null
+++ b/front_minimal/src/utils/chat-api.js
@@ -0,0 +1,60 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+export async function getConversations(params) {
+ const res = await axios.get('/chat/chats/', { params });
+ const {data} = res;
+ if (Array.isArray(data)) return { count: data.length, next: null, previous: null, results: data };
+ return {
+ count: data?.count ?? (data?.results?.length ?? 0),
+ next: data?.next ?? null,
+ previous: data?.previous ?? null,
+ results: data?.results ?? [],
+ };
+}
+
+export async function getChatById(uuid) {
+ const res = await axios.get(`/chat/chats/${uuid}/`);
+ return res.data;
+}
+
+export async function createChat(participantId) {
+ const res = await axios.post('/chat/chats/', { participants: [participantId] });
+ return res.data;
+}
+
+export async function getChatMessagesByUuid(chatUuid, params) {
+ const res = await axios.get(`/chat/chats/${chatUuid}/messages/`, { params });
+ return res.data;
+}
+
+export async function getMessages(chatId, params) {
+ const res = await axios.get('/chat/messages/', { params: { ...params, chat: chatId } });
+ return res.data;
+}
+
+export async function sendMessage(chatId, content, file) {
+ const formData = new FormData();
+ formData.append('chat', String(chatId));
+ formData.append('content', content);
+ if (file) formData.append('file', file);
+ const res = await axios.post('/chat/messages/', formData);
+ const {data} = res;
+ if (data && typeof data === 'object' && 'data' in data) return data.data;
+ return data;
+}
+
+export async function markMessagesAsRead(chatUuid, messageUuids) {
+ await axios.post(
+ `/chat/chats/${chatUuid}/mark_read/`,
+ messageUuids ? { message_uuids: messageUuids } : {}
+ );
+}
+
+export async function searchUsers(query) {
+ const res = await axios.get('/users/search/', { params: { q: query } });
+ const {data} = res;
+ if (Array.isArray(data)) return data;
+ return data?.results ?? [];
+}
diff --git a/front_minimal/src/utils/dashboard-api.js b/front_minimal/src/utils/dashboard-api.js
new file mode 100644
index 0000000..5a34a1c
--- /dev/null
+++ b/front_minimal/src/utils/dashboard-api.js
@@ -0,0 +1,203 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+/**
+ * GET /mentor/dashboard/
+ */
+export async function getMentorDashboard(options) {
+ const config = options?.signal ? { signal: options.signal } : undefined;
+ const res = await axios.get('/mentor/dashboard/', config);
+ return res.data;
+}
+
+/**
+ * GET /mentor/income/?period=week
+ */
+export async function getMentorIncome(period = 'week', startDate, endDate, options) {
+ let url = `/mentor/income/?period=${period}`;
+ if (period === 'range' && startDate && endDate) {
+ url += `&start_date=${startDate}&end_date=${endDate}`;
+ }
+ const config = options?.signal ? { signal: options.signal } : undefined;
+ const res = await axios.get(url, config);
+ return res.data;
+}
+
+/**
+ * GET /client/dashboard/
+ */
+export async function getClientDashboard(options) {
+ const config = options?.signal ? { signal: options.signal } : undefined;
+ const res = await axios.get('/client/dashboard/', config);
+ return normalizeClientDashboard(res.data);
+}
+
+/**
+ * GET /parent/{childId}/child_dashboard/
+ */
+export async function getChildDashboard(childId, options) {
+ const config = options?.signal ? { signal: options.signal } : undefined;
+ const res = await axios.get(`/parent/${childId}/child_dashboard/`, config);
+ return normalizeClientDashboard(res.data);
+}
+
+/**
+ * GET /parent/dashboard/
+ */
+export async function getParentDashboard(options) {
+ const config = options?.signal ? { signal: options.signal } : undefined;
+ const res = await axios.get('/parent/dashboard/', config);
+ return res.data;
+}
+
+// ----------------------------------------------------------------------
+// Calendar / Schedule
+
+/**
+ * GET /schedule/lessons/calendar/?start_date=&end_date=
+ */
+export async function getCalendarLessons(startDate, endDate, options) {
+ const params = new URLSearchParams({ start_date: startDate, end_date: endDate });
+ const config = options?.signal ? { signal: options.signal } : undefined;
+ const res = await axios.get(`/schedule/lessons/calendar/?${params}`, config);
+ return res.data;
+}
+
+/**
+ * GET /schedule/lessons/{id}/
+ */
+export async function getLesson(id) {
+ const res = await axios.get(`/schedule/lessons/${id}/`);
+ return res.data;
+}
+
+/**
+ * POST /schedule/lessons/
+ */
+export async function createCalendarLesson(data) {
+ const res = await axios.post('/schedule/lessons/', data);
+ return res.data;
+}
+
+/**
+ * PATCH /schedule/lessons/{id}/
+ */
+export async function updateCalendarLesson(id, data) {
+ const res = await axios.patch(`/schedule/lessons/${id}/`, data);
+ return res.data;
+}
+
+/**
+ * DELETE /schedule/lessons/{id}/
+ */
+export async function deleteCalendarLesson(id, deleteAllFuture = false) {
+ await axios.delete(`/schedule/lessons/${id}/`, {
+ data: { delete_all_future: deleteAllFuture },
+ });
+}
+
+// ----------------------------------------------------------------------
+// Students & Subjects
+
+/**
+ * GET /manage/clients/?page=1&page_size=200
+ */
+export async function getMentorStudents() {
+ const res = await axios.get('/manage/clients/?page=1&page_size=200');
+ return res.data;
+}
+
+/**
+ * GET /schedule/subjects/
+ */
+export async function getMentorSubjects() {
+ const res = await axios.get('/schedule/subjects/');
+ return res.data;
+}
+
+/**
+ * GET /schedule/lessons/ — список занятий с фильтром по статусу
+ */
+export async function getLessons(params = {}) {
+ const query = new URLSearchParams();
+ if (params.status) query.set('status', params.status);
+ if (params.start_date) query.set('start_date', params.start_date);
+ if (params.end_date) query.set('end_date', params.end_date);
+ if (params.client_id) query.set('client_id', params.client_id);
+ if (params.child_id) query.set('child_id', params.child_id);
+ const q = query.toString();
+ const res = await axios.get(`/schedule/lessons/${q ? `?${q}` : ''}`);
+ return res.data;
+}
+
+// ----------------------------------------------------------------------
+// Lesson completion / files
+
+/**
+ * POST /schedule/lessons/{id}/complete/
+ */
+export async function completeLesson(id, notes, mentorGrade, schoolGrade, homeworkText, hasHomeworkFiles, lessonFileIds) {
+ const body = {
+ notes: notes ?? '',
+ mentor_grade: mentorGrade,
+ school_grade: schoolGrade,
+ homework_text: homeworkText,
+ has_homework_files: hasHomeworkFiles,
+ };
+ if (lessonFileIds != null) {
+ body.lesson_file_ids = lessonFileIds;
+ }
+ const res = await axios.post(`/schedule/lessons/${id}/complete/`, body);
+ return res.data;
+}
+
+/**
+ * POST /schedule/lesson-files/ (multipart)
+ */
+export async function uploadLessonFile(lessonId, file) {
+ const formData = new FormData();
+ formData.append('lesson', String(lessonId));
+ formData.append('file', file);
+ const res = await axios.post('/schedule/lesson-files/', formData);
+ return res.data;
+}
+
+// ----------------------------------------------------------------------
+
+function normalizeClientDashboard(raw) {
+ const s = raw?.summary ?? {};
+ const upcoming = raw?.upcoming_lessons ?? [];
+ return {
+ total_lessons: s.total_lessons ?? 0,
+ completed_lessons: s.completed_lessons ?? 0,
+ homework_pending: s.pending_homeworks ?? 0,
+ homework_completed: s.completed_homeworks ?? 0,
+ average_grade: s.average_score ?? 0,
+ next_lesson: upcoming[0]
+ ? {
+ id: String(upcoming[0].id),
+ title: upcoming[0].title ?? '',
+ subject: '',
+ start_time: upcoming[0].start_time ?? '',
+ end_time: upcoming[0].end_time ?? '',
+ status: 'scheduled',
+ mentor: upcoming[0].mentor
+ ? { id: String(upcoming[0].mentor.id), first_name: upcoming[0].mentor.name, last_name: '' }
+ : undefined,
+ }
+ : null,
+ upcoming_lessons: upcoming.map((l) => ({
+ id: String(l.id),
+ title: l.title ?? '',
+ subject: '',
+ start_time: l.start_time ?? '',
+ end_time: l.end_time ?? '',
+ status: 'scheduled',
+ mentor: l.mentor
+ ? { id: String(l.mentor.id), first_name: l.mentor.name, last_name: '' }
+ : undefined,
+ })),
+ recent_homework: [],
+ };
+}
diff --git a/front_minimal/src/utils/livekit-api.js b/front_minimal/src/utils/livekit-api.js
new file mode 100644
index 0000000..eb6fce8
--- /dev/null
+++ b/front_minimal/src/utils/livekit-api.js
@@ -0,0 +1,28 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+/**
+ * POST /video/livekit/create-room/
+ */
+export async function createLiveKitRoom(lessonId) {
+ const res = await axios.post('/video/livekit/create-room/', { lesson_id: lessonId });
+ return res.data;
+}
+
+/**
+ * GET /video/livekit/config/
+ */
+export async function getLiveKitConfig() {
+ const res = await axios.get('/video/livekit/config/');
+ return res.data;
+}
+
+/**
+ * POST /video/livekit/participant-connected/
+ */
+export async function participantConnected({ roomName, lessonId }) {
+ const body = { room_name: roomName };
+ if (lessonId != null) body.lesson_id = lessonId;
+ await axios.post('/video/livekit/participant-connected/', body);
+}
diff --git a/front_minimal/src/utils/profile-api.js b/front_minimal/src/utils/profile-api.js
new file mode 100644
index 0000000..e415f7b
--- /dev/null
+++ b/front_minimal/src/utils/profile-api.js
@@ -0,0 +1,63 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+export async function getProfileSettings() {
+ const res = await axios.get('/profile/settings/');
+ return res.data;
+}
+
+export async function updateProfileSettings(data) {
+ await axios.patch('/profile/update_settings/', data);
+}
+
+export async function searchCities(query, limit = 50) {
+ try {
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
+ const res = await axios.get(`/profile/cities/search/?${params.toString()}`);
+ return Array.isArray(res.data) ? res.data : [];
+ } catch {
+ return [];
+ }
+}
+
+export async function updateProfile(data) {
+ const hasFile = data.avatar instanceof File;
+ if (hasFile) {
+ const formData = new FormData();
+ if (data.first_name !== undefined) formData.append('first_name', data.first_name);
+ if (data.last_name !== undefined) formData.append('last_name', data.last_name);
+ if (data.phone !== undefined) formData.append('phone', data.phone);
+ if (data.bio !== undefined) formData.append('bio', data.bio);
+ formData.append('avatar', data.avatar);
+ const res = await axios.patch('/profile/me/', formData);
+ return res.data;
+ }
+ const res = await axios.patch('/profile/me/', data);
+ return res.data;
+}
+
+export async function deleteAvatar() {
+ const res = await axios.patch('/profile/update_profile/', { avatar: null });
+ return res.data;
+}
+
+export async function loadTelegramAvatar() {
+ const res = await axios.post('/profile/load_telegram_avatar/');
+ const {data} = res;
+ return data?.user ?? data;
+}
+
+export async function getNotificationPreferences() {
+ try {
+ const res = await axios.get('/notifications/preferences/me/');
+ return res.data?.data ?? res.data;
+ } catch {
+ return null;
+ }
+}
+
+export async function updateNotificationPreferences(preferences) {
+ const res = await axios.patch('/notifications/preferences/me/', preferences);
+ return res.data?.data ?? res.data;
+}
diff --git a/front_minimal/src/utils/referrals-api.js b/front_minimal/src/utils/referrals-api.js
new file mode 100644
index 0000000..3808959
--- /dev/null
+++ b/front_minimal/src/utils/referrals-api.js
@@ -0,0 +1,30 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+export async function getReferralProfile() {
+ try {
+ const res = await axios.get('/referrals/my_profile/');
+ return res.data;
+ } catch {
+ return null;
+ }
+}
+
+export async function getReferralStats() {
+ try {
+ const res = await axios.get('/referrals/stats/');
+ return res.data;
+ } catch {
+ return null;
+ }
+}
+
+export async function getMyReferrals() {
+ const res = await axios.get('/referrals/my_referrals/');
+ return res.data;
+}
+
+export async function setReferrer(referralCode) {
+ await axios.post('/referrals/set_referrer/', { referral_code: referralCode.trim() });
+}
diff --git a/front_minimal/src/utils/telegram-api.js b/front_minimal/src/utils/telegram-api.js
new file mode 100644
index 0000000..45f562e
--- /dev/null
+++ b/front_minimal/src/utils/telegram-api.js
@@ -0,0 +1,25 @@
+import axios from 'src/utils/axios';
+
+// ----------------------------------------------------------------------
+
+export async function generateTelegramCode() {
+ const res = await axios.post('/notifications/preferences/telegram/generate-code/');
+ return res.data ?? res;
+}
+
+export async function unlinkTelegram() {
+ const res = await axios.post('/notifications/preferences/telegram/unlink/');
+ return res.data ?? res;
+}
+
+export async function getTelegramStatus() {
+ const res = await axios.get('/notifications/preferences/telegram/status/');
+ const {data} = res;
+ return data?.data ?? data;
+}
+
+export async function getTelegramBotInfo() {
+ const res = await axios.get('/notifications/preferences/telegram/bot-info/');
+ const {data} = res;
+ return data?.data ?? data;
+}
diff --git a/front_minimal/yarn.lock b/front_minimal/yarn.lock
index e392ff2..09347cd 100644
--- a/front_minimal/yarn.lock
+++ b/front_minimal/yarn.lock
@@ -1689,6 +1689,11 @@
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
+"@bufbuild/protobuf@^1.10.0":
+ version "1.10.1"
+ resolved "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz"
+ integrity sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==
+
"@dnd-kit/accessibility@^3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz"
@@ -2259,20 +2264,20 @@
resolved "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.0.tgz"
integrity sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==
-"@floating-ui/core@^1.6.0":
- version "1.6.0"
- resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz"
- integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==
+"@floating-ui/core@^1.7.3":
+ version "1.7.5"
+ resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz"
+ integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==
dependencies:
- "@floating-ui/utils" "^0.2.1"
+ "@floating-ui/utils" "^0.2.11"
-"@floating-ui/dom@^1.6.1":
- version "1.6.1"
- resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz"
- integrity sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==
+"@floating-ui/dom@^1.6.1", "@floating-ui/dom@1.7.4":
+ version "1.7.4"
+ resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz"
+ integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==
dependencies:
- "@floating-ui/core" "^1.6.0"
- "@floating-ui/utils" "^0.2.1"
+ "@floating-ui/core" "^1.7.3"
+ "@floating-ui/utils" "^0.2.10"
"@floating-ui/react-dom@^2.0.8":
version "2.0.8"
@@ -2281,10 +2286,10 @@
dependencies:
"@floating-ui/dom" "^1.6.1"
-"@floating-ui/utils@^0.2.1":
- version "0.2.1"
- resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz"
- integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
+"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.11":
+ version "0.2.11"
+ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz"
+ integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
"@fontsource/barlow@^5.0.13":
version "5.0.13"
@@ -2461,6 +2466,43 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
+"@livekit/components-core@^0.12.13", "@livekit/components-core@0.12.13":
+ version "0.12.13"
+ resolved "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz"
+ integrity sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==
+ dependencies:
+ "@floating-ui/dom" "1.7.4"
+ loglevel "1.9.1"
+ rxjs "7.8.2"
+
+"@livekit/components-react@^2.9.20":
+ version "2.9.20"
+ resolved "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.20.tgz"
+ integrity sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==
+ dependencies:
+ "@livekit/components-core" "0.12.13"
+ clsx "2.1.1"
+ events "^3.3.0"
+ jose "^6.0.12"
+ usehooks-ts "3.1.1"
+
+"@livekit/components-styles@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz"
+ integrity sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==
+
+"@livekit/mutex@1.1.1":
+ version "1.1.1"
+ resolved "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz"
+ integrity sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==
+
+"@livekit/protocol@1.44.0":
+ version "1.44.0"
+ resolved "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz"
+ integrity sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==
+ dependencies:
+ "@bufbuild/protobuf" "^1.10.0"
+
"@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2":
version "2.0.2"
resolved "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz"
@@ -2664,10 +2706,15 @@
resolved "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz"
integrity sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg==
-"@next/swc-win32-x64-msvc@14.2.4":
+"@next/swc-linux-x64-gnu@14.2.4":
version "14.2.4"
- resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.4.tgz"
- integrity sha512-tkLrjBzqFTP8DVrAAQmZelEahfR9OxWpFR++vAI9FBhCiIxtwHwBHC23SBHCTURBtwB4kc/x44imVOnkKGNVGg==
+ resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.4.tgz"
+ integrity sha512-ze0ShQDBPCqxLImzw4sCdfnB3lRmN3qGMB2GWDRlq5Wqy4G36pxtNOo2usu/Nm9+V2Rh/QQnrRc2l94kYFXO6Q==
+
+"@next/swc-linux-x64-musl@14.2.4":
+ version "14.2.4"
+ resolved "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.4.tgz"
+ integrity sha512-8dwC0UJoc6fC7PX70csdaznVMNr16hQrTDAMPvLPloazlcaWfdPogq+UpZX6Drqb1OBlwowz8iG7WR0Tzk/diQ==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -3716,6 +3763,11 @@
dependencies:
"@types/ms" "*"
+"@types/dom-mediacapture-record@^1":
+ version "1.0.22"
+ resolved "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz"
+ integrity sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==
+
"@types/geojson@*":
version "7946.0.13"
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz"
@@ -4355,7 +4407,7 @@ clone@^2.1.2:
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
-clsx@^2.1.0, clsx@^2.1.1:
+clsx@^2.1.0, clsx@^2.1.1, clsx@2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
@@ -4574,6 +4626,11 @@ data-view-byte-offset@^1.0.0:
es-errors "^1.3.0"
is-data-view "^1.0.1"
+"date-fns@^2.25.0 || ^3.2.0", date-fns@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz"
+ integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
+
dayjs@^1.10.7, dayjs@^1.11.11:
version "1.11.11"
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz"
@@ -6088,6 +6145,11 @@ jay-peg@^1.0.2:
dependencies:
restructure "^3.0.0"
+jose@^6.0.12, jose@^6.1.0:
+ version "6.2.0"
+ resolved "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz"
+ integrity sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==
+
js-cookie@^3.0.5:
version "3.0.5"
resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz"
@@ -6223,6 +6285,22 @@ linkifyjs@^4.1.0:
resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz"
integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==
+livekit-client@^2.17.2:
+ version "2.17.2"
+ resolved "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz"
+ integrity sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==
+ dependencies:
+ "@livekit/mutex" "1.1.1"
+ "@livekit/protocol" "1.44.0"
+ events "^3.3.0"
+ jose "^6.1.0"
+ loglevel "^1.9.2"
+ sdp-transform "^2.15.0"
+ ts-debounce "^4.0.0"
+ tslib "2.8.1"
+ typed-emitter "^2.1.0"
+ webrtc-adapter "^9.0.1"
+
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz"
@@ -6265,6 +6343,16 @@ lodash@^4.17.21:
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+loglevel@^1.9.2:
+ version "1.9.2"
+ resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz"
+ integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==
+
+loglevel@1.9.1:
+ version "1.9.1"
+ resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz"
+ integrity sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==
+
long@^5.0.0:
version "5.2.3"
resolved "https://registry.npmjs.org/long/-/long-5.2.3.tgz"
@@ -7413,7 +7501,7 @@ react-apexcharts@^1.4.1:
dependencies:
prop-types "^15.8.1"
-"react-dom@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react-dom@^16.11.0 || ^17 || ^18", "react-dom@^16.7.0 || ^17 || ^18 || ^19", "react-dom@^17.0.0 || ^18.0.0", react-dom@^18.0.0, react-dom@^18.2.0, react-dom@^18.3.1, "react-dom@>= 16.12.0", react-dom@>=16.3.0, react-dom@>=16.6.0, react-dom@>=16.8, react-dom@>=16.8.0, "react-dom@15 - 18":
+"react-dom@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react-dom@^16.11.0 || ^17 || ^18", "react-dom@^16.7.0 || ^17 || ^18 || ^19", "react-dom@^17.0.0 || ^18.0.0", react-dom@^18.0.0, react-dom@^18.2.0, react-dom@^18.3.1, "react-dom@>= 16.12.0", react-dom@>=16.3.0, react-dom@>=16.6.0, react-dom@>=16.8, react-dom@>=16.8.0, react-dom@>=18, "react-dom@15 - 18":
version "18.3.1"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
@@ -7551,7 +7639,7 @@ react-transition-group@^4.4.5:
loose-envify "^1.4.0"
prop-types "^15.6.2"
-"react@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react@^16.11.0 || ^17 || ^18", "react@^16.11.0 || ^17.0.0 || ^18.0.0", "react@^16.7.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.1 || ^18.0.0", "react@^17.0.0 || ^18.0.0", react@^18.0.0, react@^18.2.0, react@^18.3.1, "react@>= 16.12.0", "react@>= 16.8 || 18.0.0", "react@>= 16.8.0", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", "react@>=0.0.0 <=99", react@>=0.13, react@>=16, react@>=16.3.0, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=18, "react@15 - 18":
+"react@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react@^16.11.0 || ^17 || ^18", "react@^16.11.0 || ^17.0.0 || ^18.0.0", "react@^16.7.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.1 || ^18.0.0", "react@^17.0.0 || ^18.0.0", react@^18.0.0, react@^18.2.0, react@^18.3.1, "react@>= 16.12.0", "react@>= 16.8 || 18.0.0", "react@>= 16.8.0", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", "react@>=0.0.0 <=99", react@>=0.13, react@>=16, react@>=16.3.0, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=18, "react@15 - 18":
version "18.3.1"
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
@@ -7769,10 +7857,10 @@ rw@^1.3.3:
resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
-rxjs@^7.8.1:
- version "7.8.1"
- resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz"
- integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
+rxjs@*, rxjs@^7.8.1, rxjs@7.8.2:
+ version "7.8.2"
+ resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz"
+ integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
dependencies:
tslib "^2.1.0"
@@ -7825,6 +7913,16 @@ scrollparent@^2.1.0:
resolved "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz"
integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==
+sdp-transform@^2.15.0:
+ version "2.15.0"
+ resolved "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz"
+ integrity sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==
+
+sdp@^3.2.0:
+ version "3.2.1"
+ resolved "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz"
+ integrity sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==
+
semver@^6.3.0, semver@^6.3.1:
version "6.3.1"
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
@@ -8295,6 +8393,11 @@ ts-api-utils@^1.3.0:
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
+ts-debounce@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz"
+ integrity sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==
+
tsconfig-paths@^3.15.0:
version "3.15.0"
resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz"
@@ -8310,10 +8413,10 @@ tslib@^1.11.1:
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
-tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2:
- version "2.6.2"
- resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
- integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2, tslib@2.8.1:
+ version "2.8.1"
+ resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
+ integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
turndown@^7.2.0:
version "7.2.0"
@@ -8388,6 +8491,13 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
+typed-emitter@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz"
+ integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==
+ optionalDependencies:
+ rxjs "*"
+
typescript@^5.4.5, typescript@>=4.2.0, typescript@>=4.9.5:
version "5.4.5"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz"
@@ -8565,6 +8675,13 @@ use-sync-external-store@^1.2.0:
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
+usehooks-ts@3.1.1:
+ version "3.1.1"
+ resolved "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz"
+ integrity sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==
+ dependencies:
+ lodash.debounce "^4.0.8"
+
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
@@ -8638,6 +8755,13 @@ webidl-conversions@^3.0.0:
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+webrtc-adapter@^9.0.1:
+ version "9.0.4"
+ resolved "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz"
+ integrity sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==
+ dependencies:
+ sdp "^3.2.0"
+
websocket-driver@>=0.5.1:
version "0.7.4"
resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz"