feat: board list view, excalidraw basePath fix, analytics, feedback pages

- Board: replace blank iframe with board list (my_boards + shared_with_me),
  mentor gets per-student buttons to open/create boards,
  iframe fills full available height via CSS vars (dvh)
- Excalidraw: build with NEXT_PUBLIC_BASE_PATH=/devboard so _next/ assets
  served under /devboard/_next/ and nginx path proxy works correctly
- Nginx: preserve /devboard/ path in proxy_pass (no trailing slash),
  add /yjs WebSocket proxy to devapi.uchill.online for dev YJS on port 1235
- Analytics: real API charts (income/lessons/students) with DateRangePicker
- Feedback: mentor kanban view of completed lessons, grade + notes drawer
- Nav: dynamic role-based menu (getNavData(role)), mentor-only analytics/feedback
- New API utils: analytics-api.js, board-api.js (full), dashboard-api getLessons()
- Routes: paths.dashboard.analytics, feedback, board added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Server 2026-03-09 16:35:30 +03:00
parent d4ec417ebf
commit f679f0c0f4
87 changed files with 8927 additions and 471 deletions

View File

@ -284,11 +284,13 @@ services:
build: build:
context: ./excalidraw-server context: ./excalidraw-server
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- NEXT_PUBLIC_BASE_PATH=/devboard
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
- NEXT_PUBLIC_BASE_PATH= - NEXT_PUBLIC_BASE_PATH=/devboard
ports: ports:
- "${EXCALIDRAW_PORT:-3001}:3001" - "${EXCALIDRAW_PORT:-3001}:3001"
networks: networks:

View File

@ -16,7 +16,9 @@ RUN npx patch-package
# Копируем исходный код # Копируем исходный код
COPY . . COPY . .
# Собираем приложение # Собираем приложение (NEXT_PUBLIC_* переменные нужны на этапе сборки)
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN npm run build

View File

@ -29,6 +29,9 @@
"@fullcalendar/timeline": "^6.1.14", "@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0", "@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1", "@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/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20", "@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11", "@mui/material-nextjs": "^5.15.11",
@ -51,6 +54,7 @@
"autosuggest-highlight": "^3.3.4", "autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6", "aws-amplify": "^6.3.6",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"embla-carousel": "^8.1.5", "embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5", "embla-carousel-auto-height": "^8.1.5",
@ -62,6 +66,7 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"livekit-client": "^2.17.2",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2", "mui-one-time-password-input": "^2.0.2",
@ -3239,6 +3244,12 @@
"node": ">=6.9.0" "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": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
@ -4076,20 +4087,22 @@
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==" "integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.6.0", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.1" "@floating-ui/utils": "^0.2.11"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.6.1", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.1" "@floating-ui/utils": "^0.2.10"
} }
}, },
"node_modules/@floating-ui/react-dom": { "node_modules/@floating-ui/react-dom": {
@ -4105,9 +4118,10 @@
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.1", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
}, },
"node_modules/@fontsource/barlow": { "node_modules/@fontsource/barlow": {
"version": "5.0.13", "version": "5.0.13",
@ -4382,6 +4396,76 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
@ -6740,6 +6824,13 @@
"@types/ms": "*" "@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": { "node_modules/@types/geojson": {
"version": "7946.0.13", "version": "7946.0.13",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
@ -7994,6 +8085,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/dayjs": {
"version": "1.11.11", "version": "1.11.11",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
@ -10610,6 +10711,15 @@
"restructure": "^3.0.0" "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": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "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", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" "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": { "node_modules/long": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
@ -13237,9 +13394,10 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@ -13323,6 +13481,21 @@
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==" "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": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -14044,6 +14217,12 @@
"typescript": ">=4.2.0" "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": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -14069,9 +14248,10 @@
} }
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.6.2", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}, },
"node_modules/turndown": { "node_modules/turndown": {
"version": "7.2.0", "version": "7.2.0",
@ -14185,6 +14365,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/typescript": {
"version": "5.4.5", "version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "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" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" "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": { "node_modules/websocket-driver": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",

View File

@ -40,6 +40,9 @@
"@fullcalendar/timeline": "^6.1.14", "@fullcalendar/timeline": "^6.1.14",
"@hookform/resolvers": "^3.6.0", "@hookform/resolvers": "^3.6.0",
"@iconify/react": "^5.0.1", "@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/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20", "@mui/material": "^5.15.20",
"@mui/material-nextjs": "^5.15.11", "@mui/material-nextjs": "^5.15.11",
@ -62,6 +65,7 @@
"autosuggest-highlight": "^3.3.4", "autosuggest-highlight": "^3.3.4",
"aws-amplify": "^6.3.6", "aws-amplify": "^6.3.6",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"embla-carousel": "^8.1.5", "embla-carousel": "^8.1.5",
"embla-carousel-auto-height": "^8.1.5", "embla-carousel-auto-height": "^8.1.5",
@ -73,6 +77,7 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"livekit-client": "^2.17.2",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.4.0",
"mui-one-time-password-input": "^2.0.2", "mui-one-time-password-input": "^2.0.2",

View File

@ -1,43 +1,61 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
import useSWR, { mutate } from 'swr'; 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 STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
const SUBJECTS_ENDPOINT = '/schedule/subjects/'; const SUBJECTS_ENDPOINT = '/schedule/subjects/';
const swrOptions = { const swrOptions = {
revalidateIfStale: true, revalidateIfStale: true,
revalidateOnFocus: true, revalidateOnFocus: false,
revalidateOnReconnect: true, 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() { export function useGetEvents(currentDate) {
const startDate = '2026-02-01'; const date = currentDate || new Date();
const endDate = '2026-04-30'; 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( const { data: response, isLoading, error, isValidating } = useSWR(
[CALENDAR_ENDPOINT, startDate, endDate], ['calendar', start, end],
([url, start, end]) => getCalendarLessons(start, end), ([, s, e]) => getCalendarLessons(s, e),
swrOptions swrOptions
); );
const memoizedValue = useMemo(() => { const memoizedValue = useMemo(() => {
const lessonsArray = response?.data?.lessons || []; const lessonsArray = response?.data?.lessons || response?.lessons || [];
const events = lessonsArray.map((lesson) => { const events = lessonsArray.map((lesson) => {
const start = lesson.start_time || lesson.start; const start = lesson.start_time || lesson.start;
const end = lesson.end_time || lesson.end || start; const end = lesson.end_time || lesson.end || start;
const startTimeStr = start ? new Date(start).toLocaleTimeString('ru-RU', { const startTimeStr = start
? new Date(start).toLocaleTimeString('ru-RU', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hourCycle: 'h23' hourCycle: 'h23',
}) : ''; })
: '';
const subject = lesson.subject_name || lesson.subject || 'Урок'; const subject = lesson.subject_name || lesson.subject || 'Урок';
const student = lesson.client_name || ''; const student = lesson.client_name || '';
@ -83,14 +101,16 @@ export function useGetEvents() {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function useGetStudents() { 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(() => { return useMemo(() => {
const rawData = response?.data?.results || response?.results || response || []; const rawData = response?.data?.results || response?.results || response || [];
const studentsArray = Array.isArray(rawData) ? rawData : [];
return { return {
students: studentsArray, students: Array.isArray(rawData) ? rawData : [],
studentsLoading: isLoading, studentsLoading: isLoading,
studentsError: error, studentsError: error,
}; };
@ -98,14 +118,16 @@ export function useGetStudents() {
} }
export function useGetSubjects() { 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(() => { return useMemo(() => {
const rawData = response?.data || response?.results || response || []; const rawData = response?.data || response?.results || response || [];
const subjectsArray = Array.isArray(rawData) ? rawData : [];
return { return {
subjects: subjectsArray, subjects: Array.isArray(rawData) ? rawData : [],
subjectsLoading: isLoading, subjectsLoading: isLoading,
subjectsError: error, subjectsError: error,
}; };
@ -114,25 +136,53 @@ export function useGetSubjects() {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export async function createEvent(eventData) { function revalidateCalendar(date) {
const payload = { const d = date || new Date();
client: String(eventData.client), const start = format(startOfMonth(subMonths(d, 1)), 'yyyy-MM-dd');
title: eventData.title.replace(' - ', ' — '), const end = format(endOfMonth(addMonths(d, 1)), 'yyyy-MM-dd');
description: eventData.description, mutate(['calendar', start, end]);
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;
} }
export async function updateEvent(eventData) { console.log('Update Event:', eventData); } export async function createEvent(eventData, currentDate) {
export async function deleteEvent(eventId) { console.log('Delete Event:', eventId); } 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);
}

43
front_minimal/src/app.jsx Normal file
View File

@ -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 (
<BrowserRouter>
<I18nProvider>
<LocalizationProvider>
<AuthProvider>
<SettingsProvider settings={defaultSettings} caches="localStorage">
<ThemeProvider>
<MotionLazy>
<CheckoutProvider>
<Snackbar />
<ProgressBar />
<SettingsDrawer />
<Router />
</CheckoutProvider>
</MotionLazy>
</ThemeProvider>
</SettingsProvider>
</AuthProvider>
</LocalizationProvider>
</I18nProvider>
</BrowserRouter>
);
}

View File

@ -0,0 +1,13 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
import { GuestGuard } from 'src/auth/guard';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return (
<GuestGuard>
<AuthSplitLayout>{children}</AuthSplitLayout>
</GuestGuard>
);
}

View File

@ -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 <JwtForgotPasswordView />;
}

View File

@ -0,0 +1,13 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
import { GuestGuard } from 'src/auth/guard';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return (
<GuestGuard>
<AuthSplitLayout>{children}</AuthSplitLayout>
</GuestGuard>
);
}

View File

@ -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 <JwtResetPasswordView />;
}

View File

@ -0,0 +1,7 @@
import { AuthSplitLayout } from 'src/layouts/auth-split';
// ----------------------------------------------------------------------
export default function Layout({ children }) {
return <AuthSplitLayout>{children}</AuthSplitLayout>;
}

View File

@ -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 <JwtVerifyEmailView />;
}

View File

@ -1,11 +1,11 @@
import { CONFIG } from 'src/config-global'; 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() { export default function Page() {
return <OverviewAnalyticsView />; return <AnalyticsView />;
} }

View File

@ -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 <BoardView />;
}

View File

@ -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 <ChatPlatformView />;
}

View File

@ -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 <ChildrenProgressView />;
}

View File

@ -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 <ChildrenView />;
}

View File

@ -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 <FeedbackView />;
}

View File

@ -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 <MyProgressView />;
}

View File

@ -1,38 +1,65 @@
'use client'; '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 { useAuthContext } from 'src/auth/hooks';
// Временно импортируем только ментора (позже добавим клиента и родителя)
import { OverviewCourseView } from 'src/sections/overview/course/view'; 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(); 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) { if (loading) {
return <div>Загрузка...</div>; return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
} }
if (!user) { if (!user) return null;
return null;
}
// Роутинг по ролям
if (user.role === 'mentor') { if (user.role === 'mentor') {
return <OverviewCourseView />; return <OverviewCourseView />;
} }
if (user.role === 'client') { if (user.role === 'client') {
return <div>Дашборд Клиента (в разработке)</div>; return <OverviewClientView />;
} }
if (user.role === 'parent') { if (user.role === 'parent') {
return <div>Дашборд Родителя (в разработке)</div>; return (
<OverviewClientView
childId={selectedChild?.id || null}
childName={selectedChild?.name || null}
/>
);
} }
return ( return (
<div style={{ padding: '24px', textAlign: 'center' }}> <Box sx={{ p: 3, textAlign: 'center' }}>
<p>Неизвестная роль пользователя: {user.role}</p> <Typography color="text.secondary">Неизвестная роль: {user.role}</Typography>
</div> </Box>
); );
} }

View File

@ -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 <PaymentPlatformView />;
}

View File

@ -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 <AccountPlatformView />;
}

View File

@ -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 <ReferralsView />;
}

View File

@ -0,0 +1,5 @@
// Fullscreen layout no sidebar or header
export default function VideoCallLayout({ children }) {
return children;
}

View File

@ -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 <VideoCallView />;
}

View File

@ -3,25 +3,24 @@
import axios, { endpoints } from 'src/utils/axios'; import axios, { endpoints } from 'src/utils/axios';
import { setSession } from './utils'; import { setSession } from './utils';
import { STORAGE_KEY } from './constant'; import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
/** ************************************** /** **************************************
* Sign in * Sign in
*************************************** */ *************************************** */
export const signInWithPassword = async ({ email, password }) => { export const signInWithPassword = async ({ email, password }) => {
try { try {
const params = { email, password }; const res = await axios.post(endpoints.auth.signIn, { email, password });
const res = await axios.post(endpoints.auth.signIn, params); const data = res.data?.data;
const accessToken = data?.tokens?.access;
// Адаптация под твой API: { data: { tokens: { access } } } const refreshToken = data?.tokens?.refresh;
const accessToken = res.data?.data?.tokens?.access;
if (!accessToken) { if (!accessToken) {
throw new Error('Access token not found in response'); throw new Error('Access token not found in response');
} }
setSession(accessToken); await setSession(accessToken, refreshToken);
} catch (error) { } catch (error) {
console.error('Error during sign in:', error); console.error('Error during sign in:', error);
throw error; throw error;
@ -31,24 +30,32 @@ export const signInWithPassword = async ({ email, password }) => {
/** ************************************** /** **************************************
* Sign up * Sign up
*************************************** */ *************************************** */
export const signUp = async ({ email, password, firstName, lastName }) => { export const signUp = async ({ email, password, passwordConfirm, firstName, lastName, role, city, timezone }) => {
try {
const params = { const params = {
email, email,
password, password,
firstName, password_confirm: passwordConfirm,
lastName, first_name: firstName,
last_name: lastName,
role: role || 'client',
city: city || '',
timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
}; };
try {
const res = await axios.post(endpoints.auth.signUp, params); 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) { 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) { } catch (error) {
console.error('Error during sign up:', error); console.error('Error during sign up:', error);
throw error; throw error;
@ -66,3 +73,67 @@ export const signOut = async () => {
throw error; 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;
}
};

View File

@ -3,9 +3,10 @@
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';
import { useSetState } from 'src/hooks/use-set-state'; import { useSetState } from 'src/hooks/use-set-state';
import axios, { endpoints } from 'src/utils/axios'; 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 { AuthContext } from '../auth-context';
import { setSession, isValidToken } from './utils'; import { setSession, isValidToken } from './utils';
import { refreshAccessToken } from './action';
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const { state, setState } = useSetState({ const { state, setState } = useSetState({
@ -15,25 +16,28 @@ export function AuthProvider({ children }) {
const checkUserSession = useCallback(async () => { const checkUserSession = useCallback(async () => {
try { try {
const accessToken = sessionStorage.getItem(STORAGE_KEY); let accessToken = localStorage.getItem(STORAGE_KEY);
if (accessToken && isValidToken(accessToken)) { if (accessToken && isValidToken(accessToken)) {
setSession(accessToken); setSession(accessToken);
} else {
// Пробуем обновить через refresh token
try {
accessToken = await refreshAccessToken();
} catch {
setState({ user: null, loading: false });
return;
}
}
const res = await axios.get(endpoints.auth.me); const res = await axios.get(endpoints.auth.me);
// Гарантируем получение объекта пользователя из data
const userData = res.data?.data || res.data; const userData = res.data?.data || res.data;
// Если прилетел массив или невалидный объект - сбрасываем
if (!userData || typeof userData !== 'object' || Array.isArray(userData)) { if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
throw new Error('Invalid user data format'); throw new Error('Invalid user data format');
} }
setState({ user: { ...userData, accessToken }, loading: false }); setState({ user: { ...userData, accessToken }, loading: false });
} else {
setState({ user: null, loading: false });
}
} catch (error) { } catch (error) {
console.error('[Auth Debug]:', error); console.error('[Auth Debug]:', error);
setState({ user: null, loading: false }); setState({ user: null, loading: false });
@ -52,7 +56,7 @@ export function AuthProvider({ children }) {
user: state.user user: state.user
? { ? {
...state.user, ...state.user,
role: state.user?.role ?? 'admin', role: state.user?.role ?? 'client',
} }
: null, : null,
checkUserSession, checkUserSession,

View File

@ -1 +1,2 @@
export const STORAGE_KEY = 'jwt_access_token'; export const STORAGE_KEY = 'jwt_access_token';
export const REFRESH_STORAGE_KEY = 'jwt_refresh_token';

View File

@ -2,7 +2,7 @@ import { paths } from 'src/routes/paths';
import axios from 'src/utils/axios'; 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(() => { setTimeout(() => {
try { try {
alert('Token expired!'); localStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(REFRESH_STORAGE_KEY);
window.location.href = paths.auth.jwt.signIn; window.location.href = paths.auth.jwt.signIn;
} catch (error) { } catch (error) {
console.error('Error during token expiration:', error); console.error('Error during token expiration:', error);
throw error;
} }
}, timeLeft); }, timeLeft);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export async function setSession(accessToken) { export async function setSession(accessToken, refreshToken) {
try { try {
if (accessToken) { 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}`; 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) { if (decodedToken && 'exp' in decodedToken) {
tokenExpired(decodedToken.exp); tokenExpired(decodedToken.exp);
@ -84,7 +87,8 @@ export async function setSession(accessToken) {
throw new Error('Invalid access token!'); throw new Error('Invalid access token!');
} }
} else { } else {
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(REFRESH_STORAGE_KEY);
delete axios.defaults.headers.common.Authorization; delete axios.defaults.headers.common.Authorization;
} }
} catch (error) { } catch (error) {

View File

@ -1,21 +1,10 @@
import dynamic from 'next/dynamic'; import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { withLoadingProps } from 'src/utils/with-loading-props';
import { ChartLoading } from './chart-loading'; import { ChartLoading } from './chart-loading';
const ApexChart = withLoadingProps((props) => const ApexChart = lazy(() => import('react-apexcharts').then((mod) => ({ default: mod.default })));
dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
ssr: false,
loading: () => {
const { loading, type } = props();
return loading?.disabled ? null : <ChartLoading type={type} sx={loading?.sx} />;
},
})
);
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -41,6 +30,13 @@ export function Chart({
...sx, ...sx,
}} }}
{...other} {...other}
>
<Suspense
fallback={
loadingProps?.disabled ? null : (
<ChartLoading type={type} sx={loadingProps?.sx} />
)
}
> >
<ApexChart <ApexChart
type={type} type={type}
@ -48,8 +44,8 @@ export function Chart({
options={options} options={options}
width="100%" width="100%"
height="100%" height="100%"
loading={loadingProps}
/> />
</Suspense>
</Box> </Box>
); );
} }

View File

@ -1,5 +1,4 @@
import dynamic from 'next/dynamic'; import { lazy, Suspense, cloneElement } from 'react';
import { cloneElement } from 'react';
import { useTheme } from '@mui/material/styles'; 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), { const Tree = lazy(() =>
ssr: false, import('react-organizational-chart').then((mod) => ({ default: mod.Tree }))
}); );
const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), { const TreeNode = lazy(() =>
ssr: false, import('react-organizational-chart').then((mod) => ({ default: mod.TreeNode }))
}); );
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -27,6 +26,7 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
}); });
return ( return (
<Suspense fallback={null}>
<Tree <Tree
lineWidth="1.5px" lineWidth="1.5px"
nodePadding="4px" nodePadding="4px"
@ -39,6 +39,7 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
<TreeList key={index} depth={1} data={list} nodeItem={nodeItem} /> <TreeList key={index} depth={1} data={list} nodeItem={nodeItem} />
))} ))}
</Tree> </Tree>
</Suspense>
); );
} }

View File

@ -1,13 +1,6 @@
import { cookies } from 'next/headers'; // Stub — server-side functions not used in Vite SPA
// Settings are always read from localStorage
import { STORAGE_KEY, defaultSettings } from './config-settings';
// ----------------------------------------------------------------------
export async function detectSettings() { export async function detectSettings() {
const cookieStore = cookies(); return undefined;
const settingsStore = cookieStore.get(STORAGE_KEY);
return settingsStore ? JSON.parse(settingsStore?.value) : defaultSettings;
} }

View File

@ -1,4 +1,4 @@
import dynamic from 'next/dynamic'; import { lazy, Suspense } from 'react';
import { useTheme } from '@mui/material/styles'; 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), { const Joyride = lazy(() => import('react-joyride').then((mod) => ({ default: mod.default })));
ssr: false,
});
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -32,6 +30,7 @@ export function Walktour({
}; };
return ( return (
<Suspense fallback={null}>
<Joyride <Joyride
scrollOffset={100} scrollOffset={100}
locale={{ last: 'Done', ...locale }} locale={{ last: 'Done', ...locale }}
@ -71,5 +70,6 @@ export function Walktour({
}} }}
{...other} {...other}
/> />
</Suspense>
); );
} }

View File

@ -6,16 +6,17 @@ import packageJson from '../package.json';
export const CONFIG = { export const CONFIG = {
site: { site: {
name: 'Minimals', name: 'Platform',
serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '', serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '', assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '',
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '', basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '',
version: packageJson.version, version: packageJson.version,
}, },
isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`), // В Next.js всегда работаем как SPA (без SSR для наших страниц)
isStaticExport: true,
/** /**
* Auth * Auth
* @method jwt | amplify | firebase | supabase | auth0 * @method jwt
*/ */
auth: { auth: {
method: 'jwt', method: 'jwt',

View File

@ -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 };
}

View File

@ -14,32 +14,65 @@ const ICONS = {
dashboard: icon('ic-dashboard'), dashboard: icon('ic-dashboard'),
kanban: icon('ic-kanban'), kanban: icon('ic-kanban'),
folder: icon('ic-folder'), folder: icon('ic-folder'),
analytics: icon('ic-analytics'),
label: icon('ic-label'),
}; };
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const navData = [ export function getNavData(role) {
/** const isMentor = role === 'mentor';
* Основное const isClient = role === 'client';
*/ const isParent = role === 'parent';
return [
{ {
subheader: 'Главная', subheader: 'Главная',
items: [ items: [
{ title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard }, { title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard },
], ],
}, },
/**
* Управление
*/
{ {
subheader: 'Инструменты', subheader: 'Инструменты',
items: [ items: [
{ title: 'Ученики', path: paths.dashboard.students, icon: ICONS.user }, // Ученики/Менторы для всех ролей (разный контент внутри)
{ title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user },
{ title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar }, { title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar },
{ title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban }, { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban },
{ title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder }, { title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder },
{ title: 'Чат', path: paths.dashboard.chat, icon: ICONS.chat }, { title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban },
{ title: 'Уведомления', path: paths.dashboard.notifications, icon: icon('ic-label') }, { 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');

View File

@ -24,7 +24,8 @@ import { _account } from '../config-nav-account';
import { HeaderBase } from '../core/header-base'; import { HeaderBase } from '../core/header-base';
import { _workspaces } from '../config-nav-workspace'; import { _workspaces } from '../config-nav-workspace';
import { LayoutSection } from '../core/layout-section'; 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 settings = useSettingsContext();
const { user } = useAuthContext();
const navColorVars = useNavColorVars(theme, settings); const navColorVars = useNavColorVars(theme, settings);
const layoutQuery = 'lg'; const layoutQuery = 'lg';
const navData = data?.nav ?? dashboardNavData; const navData = data?.nav ?? getNavData(user?.role);
const isNavMini = settings.navLayout === 'mini'; const isNavMini = settings.navLayout === 'mini';

View File

@ -1,51 +1,6 @@
import { cache } from 'react'; // Stub — server-side functions not used in Vite SPA
import { createInstance } from 'i18next'; // Language detection is handled by i18n-provider via localStorage
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
*/
export async function detectLanguage() { export async function detectLanguage() {
const cookies = getCookies(); return undefined;
const language = cookies.get(cookieName)?.value ?? fallbackLng;
return language;
} }
// ----------------------------------------------------------------------
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;
};

View File

@ -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(
<StrictMode>
<App />
</StrictMode>
);

View File

@ -17,6 +17,7 @@ const ROOTS = {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const paths = { export const paths = {
videoCall: '/video-call',
comingSoon: '/coming-soon', comingSoon: '/coming-soon',
maintenance: '/maintenance', maintenance: '/maintenance',
pricing: '/pricing', pricing: '/pricing',
@ -106,6 +107,16 @@ export const paths = {
materials: `${ROOTS.DASHBOARD}/materials`, materials: `${ROOTS.DASHBOARD}/materials`,
students: `${ROOTS.DASHBOARD}/students`, students: `${ROOTS.DASHBOARD}/students`,
notifications: `${ROOTS.DASHBOARD}/notifications`, 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`, fileManager: `${ROOTS.DASHBOARD}/file-manager`,
permission: `${ROOTS.DASHBOARD}/permission`, permission: `${ROOTS.DASHBOARD}/permission`,
general: { general: {

View File

@ -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 <SplashScreen />;
}
function DashboardLayoutWrapper() {
return (
<AuthGuard>
<DashboardLayout>
<Outlet />
</DashboardLayout>
</AuthGuard>
);
}
function AuthLayoutWrapper() {
return (
<GuestGuard>
<AuthSplitLayout>
<Outlet />
</AuthSplitLayout>
</GuestGuard>
);
}
// ----------------------------------------------------------------------
export function Router() {
return useRoutes([
// Root redirect
{
path: '/',
element: <Navigate to="/dashboard/analytics" replace />,
},
// Auth - JWT
{
path: 'auth/jwt',
element: <AuthLayoutWrapper />,
children: [
{
path: 'sign-in',
element: (
<Suspense fallback={<Loading />}>
<JwtSignInView />
</Suspense>
),
},
{
path: 'sign-up',
element: (
<Suspense fallback={<Loading />}>
<JwtSignUpView />
</Suspense>
),
},
{
path: 'forgot-password',
element: (
<Suspense fallback={<Loading />}>
<JwtForgotPasswordView />
</Suspense>
),
},
{
path: 'reset-password',
element: (
<Suspense fallback={<Loading />}>
<JwtResetPasswordView />
</Suspense>
),
},
],
},
// Verify email без GuestGuard
{
path: 'auth/jwt/verify-email',
element: (
<AuthSplitLayout>
<Suspense fallback={<Loading />}>
<JwtVerifyEmailView />
</Suspense>
</AuthSplitLayout>
),
},
// Dashboard
{
path: 'dashboard',
element: <DashboardLayoutWrapper />,
children: [
{ index: true, element: <Navigate to="analytics" replace /> },
// Overview
{
path: 'analytics',
element: (
<Suspense fallback={<Loading />}>
<OverviewAnalyticsView />
</Suspense>
),
},
{
path: 'ecommerce',
element: (
<Suspense fallback={<Loading />}>
<OverviewEcommerceView />
</Suspense>
),
},
{
path: 'banking',
element: (
<Suspense fallback={<Loading />}>
<OverviewBankingView />
</Suspense>
),
},
{
path: 'booking',
element: (
<Suspense fallback={<Loading />}>
<OverviewBookingView />
</Suspense>
),
},
{
path: 'file',
element: (
<Suspense fallback={<Loading />}>
<OverviewFileView />
</Suspense>
),
},
{
path: 'course',
element: (
<Suspense fallback={<Loading />}>
<OverviewCourseView />
</Suspense>
),
},
// Features
{
path: 'schedule',
element: (
<Suspense fallback={<Loading />}>
<CalendarView />
</Suspense>
),
},
{
path: 'chat',
element: (
<Suspense fallback={<Loading />}>
<ChatView />
</Suspense>
),
},
{
path: 'mail',
element: (
<Suspense fallback={<Loading />}>
<MailView />
</Suspense>
),
},
{
path: 'kanban',
element: (
<Suspense fallback={<Loading />}>
<KanbanView />
</Suspense>
),
},
{
path: 'file-manager',
element: (
<Suspense fallback={<Loading />}>
<FileManagerView />
</Suspense>
),
},
{
path: 'permission',
element: (
<Suspense fallback={<Loading />}>
<PermissionDeniedView />
</Suspense>
),
},
{
path: 'blank',
element: (
<Suspense fallback={<Loading />}>
<BlankView />
</Suspense>
),
},
// User
{
path: 'user',
children: [
{ index: true, element: <Navigate to="profile" replace /> },
{
path: 'profile',
element: (
<Suspense fallback={<Loading />}>
<UserProfileView />
</Suspense>
),
},
{
path: 'list',
element: (
<Suspense fallback={<Loading />}>
<UserListView />
</Suspense>
),
},
{
path: 'cards',
element: (
<Suspense fallback={<Loading />}>
<UserCardsView />
</Suspense>
),
},
{
path: 'new',
element: (
<Suspense fallback={<Loading />}>
<UserCreateView />
</Suspense>
),
},
{
path: ':id/edit',
element: (
<Suspense fallback={<Loading />}>
<UserEditView />
</Suspense>
),
},
{
path: 'account',
element: (
<Suspense fallback={<Loading />}>
<AccountView />
</Suspense>
),
},
],
},
// Product
{
path: 'product',
children: [
{
index: true,
element: (
<Suspense fallback={<Loading />}>
<ProductListView />
</Suspense>
),
},
{
path: 'new',
element: (
<Suspense fallback={<Loading />}>
<ProductCreateView />
</Suspense>
),
},
{
path: ':id',
element: (
<Suspense fallback={<Loading />}>
<ProductDetailsView />
</Suspense>
),
},
{
path: ':id/edit',
element: (
<Suspense fallback={<Loading />}>
<ProductEditView />
</Suspense>
),
},
],
},
// Order
{
path: 'order',
children: [
{
index: true,
element: (
<Suspense fallback={<Loading />}>
<OrderListView />
</Suspense>
),
},
{
path: ':id',
element: (
<Suspense fallback={<Loading />}>
<OrderDetailsView />
</Suspense>
),
},
],
},
// Invoice
{
path: 'invoice',
children: [
{
index: true,
element: (
<Suspense fallback={<Loading />}>
<InvoiceListView />
</Suspense>
),
},
{
path: 'new',
element: (
<Suspense fallback={<Loading />}>
<InvoiceCreateView />
</Suspense>
),
},
{
path: ':id',
element: (
<Suspense fallback={<Loading />}>
<InvoiceDetailsView />
</Suspense>
),
},
{
path: ':id/edit',
element: (
<Suspense fallback={<Loading />}>
<InvoiceEditView />
</Suspense>
),
},
],
},
// Blog / Post
{
path: 'post',
children: [
{
index: true,
element: (
<Suspense fallback={<Loading />}>
<PostListView />
</Suspense>
),
},
{
path: 'new',
element: (
<Suspense fallback={<Loading />}>
<PostCreateView />
</Suspense>
),
},
{
path: ':title',
element: (
<Suspense fallback={<Loading />}>
<PostDetailsView />
</Suspense>
),
},
{
path: ':title/edit',
element: (
<Suspense fallback={<Loading />}>
<PostEditView />
</Suspense>
),
},
],
},
// Job
{
path: 'job',
children: [
{
index: true,
element: (
<Suspense fallback={<Loading />}>
<JobListView />
</Suspense>
),
},
{
path: 'new',
element: (
<Suspense fallback={<Loading />}>
<JobCreateView />
</Suspense>
),
},
{
path: ':id',
element: (
<Suspense fallback={<Loading />}>
<JobDetailsView />
</Suspense>
),
},
{
path: ':id/edit',
element: (
<Suspense fallback={<Loading />}>
<JobEditView />
</Suspense>
),
},
],
},
// Tour
{
path: 'tour',
children: [
{
index: true,
element: (
<Suspense fallback={<Loading />}>
<TourListView />
</Suspense>
),
},
{
path: 'new',
element: (
<Suspense fallback={<Loading />}>
<TourCreateView />
</Suspense>
),
},
{
path: ':id',
element: (
<Suspense fallback={<Loading />}>
<TourDetailsView />
</Suspense>
),
},
{
path: ':id/edit',
element: (
<Suspense fallback={<Loading />}>
<TourEditView />
</Suspense>
),
},
],
},
],
},
// Error pages
{ path: '403', element: <Suspense fallback={<Loading />}><Page403 /></Suspense> },
{ path: '404', element: <Suspense fallback={<Loading />}><Page404 /></Suspense> },
{ path: '500', element: <Suspense fallback={<Loading />}><Page500 /></Suspense> },
// Catch-all
{ path: '*', element: <Navigate to="/404" replace /> },
]);
}

View File

@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
);
}
const botLink = botInfo?.link || (botInfo?.username ? `https://t.me/${botInfo.username}` : null);
return (
<>
<Box>
{status?.linked ? (
<Stack spacing={1}>
<Stack direction="row" alignItems="center" spacing={1}>
<Iconify icon="logos:telegram" width={20} />
<Typography variant="body2" color="success.main" fontWeight={600}>
Telegram подключён
</Typography>
{status.telegram_username && (
<Typography variant="body2" color="text.secondary">
@{status.telegram_username}
</Typography>
)}
</Stack>
<Stack direction="row" spacing={1}>
<LoadingButton
size="small"
variant="soft"
color="info"
loading={loadingTgAvatar}
onClick={handleLoadTgAvatar}
startIcon={<Iconify icon="solar:user-id-bold" />}
>
Загрузить фото из Telegram
</LoadingButton>
<LoadingButton
size="small"
variant="soft"
color="error"
loading={unlinking}
onClick={handleUnlink}
startIcon={<Iconify icon="solar:link-broken-bold" />}
>
Отвязать
</LoadingButton>
</Stack>
</Stack>
) : (
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
Подключите Telegram для уведомлений
</Typography>
<LoadingButton
size="small"
variant="soft"
color="primary"
loading={generating}
onClick={handleGenerate}
startIcon={<Iconify icon="logos:telegram" />}
>
Привязать Telegram
</LoadingButton>
</Stack>
)}
</Box>
{/* Code Modal */}
<Dialog open={codeModal} onClose={handleCloseModal} maxWidth="xs" fullWidth>
<DialogTitle>Привязать Telegram</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
<Typography variant="body2">
1. Откройте бота{' '}
{botLink ? (
<Box component="a" href={botLink} target="_blank" rel="noopener noreferrer" sx={{ color: 'primary.main' }}>
{botInfo?.username ? `@${botInfo.username}` : 'Telegram бот'}
</Box>
) : (
'Telegram бот'
)}{' '}
в Telegram
</Typography>
<Typography variant="body2">2. Отправьте боту команду:</Typography>
<Paper
variant="outlined"
sx={{
p: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: 'background.neutral',
borderRadius: 1,
fontFamily: 'monospace',
}}
>
<Typography variant="body2" fontFamily="monospace" fontWeight={700}>
/link {code}
</Typography>
<Tooltip title={copied ? 'Скопировано!' : 'Скопировать'}>
<IconButton size="small" onClick={handleCopy}>
<Iconify icon={copied ? 'solar:check-circle-bold' : 'solar:copy-bold'} width={18} />
</IconButton>
</Tooltip>
</Paper>
{codeInstructions && (
<Typography variant="caption" color="text.secondary">
{codeInstructions}
</Typography>
)}
<Stack direction="row" alignItems="center" spacing={1}>
<CircularProgress size={14} />
<Typography variant="caption" color="text.secondary">
Ожидаем подтверждения...
</Typography>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseModal} color="inherit">
Закрыть
</Button>
</DialogActions>
</Dialog>
</>
);
}
// ----------------------------------------------------------------------
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 (
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 180 }}>Тип уведомления</TableCell>
{CHANNELS.map((ch) => (
<TableCell key={ch.key} align="center" sx={{ minWidth: 100 }}>
{ch.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{visibleTypes.map((type) => (
<TableRow key={type.key} hover>
<TableCell>
<Typography variant="body2">{type.label}</Typography>
</TableCell>
{CHANNELS.map((ch) => (
<TableCell key={ch.key} align="center">
<Switch
size="small"
checked={getTypeValue(type.key, ch.key)}
onChange={() => handleToggle(type.key, ch.key)}
disabled={!prefs?.enabled}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
// ----------------------------------------------------------------------
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 (
<DashboardContent>
<CustomBreadcrumbs
heading="Профиль"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Профиль' }]}
sx={{ mb: 3 }}
/>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '440px 1fr' },
gap: 3,
alignItems: 'start',
}}
>
{/* ─── LEFT COLUMN ─── */}
<Stack spacing={3}>
{/* Avatar card */}
<Card>
<CardContent sx={{ pb: '16px !important' }}>
<Stack alignItems="center" spacing={2}>
{/* Square avatar with hover overlay */}
<Box
sx={{ position: 'relative', width: 160, height: 160, cursor: 'pointer' }}
onMouseEnter={() => setAvatarHovered(true)}
onMouseLeave={() => setAvatarHovered(false)}
>
<Avatar
src={currentAvatar}
alt={displayName}
sx={{ width: 160, height: 160, borderRadius: 2, fontSize: 48 }}
>
{!currentAvatar && displayName[0]?.toUpperCase()}
</Avatar>
{/* Hover overlay */}
{avatarHovered && (
<Box
sx={{
position: 'absolute',
inset: 0,
borderRadius: 2,
bgcolor: 'rgba(0,0,0,0.55)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
}}
>
<input
type="file"
id="avatar-upload"
accept="image/*"
onChange={handleAvatarChange}
style={{ display: 'none' }}
/>
<Tooltip title="Загрузить фото">
<IconButton
component="label"
htmlFor="avatar-upload"
size="small"
sx={{ color: 'white' }}
disabled={uploadingAvatar}
>
{uploadingAvatar ? (
<CircularProgress size={20} color="inherit" />
) : (
<Iconify icon="solar:camera-bold" />
)}
</IconButton>
</Tooltip>
{currentAvatar && (
<Tooltip title="Удалить фото">
<IconButton
size="small"
sx={{ color: 'white' }}
onClick={handleDeleteAvatar}
disabled={deletingAvatar}
>
{deletingAvatar ? (
<CircularProgress size={20} color="inherit" />
) : (
<Iconify icon="solar:trash-bin-trash-bold" />
)}
</IconButton>
</Tooltip>
)}
</Box>
)}
</Box>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="subtitle1">{displayName}</Typography>
<Typography variant="body2" color="text.secondary">{user?.email}</Typography>
{roleLabel && (
<Chip label={roleLabel} size="small" sx={{ mt: 0.5 }} />
)}
</Box>
</Stack>
</CardContent>
</Card>
{/* Profile fields */}
<Card>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Личные данные
</Typography>
<Stack spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="Имя"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={(e) => handleProfileBlur('first_name', e.target.value.trim())}
fullWidth
/>
<TextField
label="Фамилия"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onBlur={(e) => handleProfileBlur('last_name', e.target.value.trim())}
fullWidth
/>
</Stack>
<TextField
label="Телефон"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onBlur={(e) => handleProfileBlur('phone', e.target.value.trim())}
fullWidth
/>
<TextField
label="Email"
value={email}
fullWidth
disabled
helperText="Изменить email нельзя"
/>
</Stack>
</CardContent>
</Card>
{/* City + Timezone */}
<Card>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Местоположение
</Typography>
<Stack spacing={2}>
{settingsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : (
<>
<Autocomplete
freeSolo
options={cityOptions}
getOptionLabel={(opt) =>
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) => (
<TextField
{...params}
label="Город"
fullWidth
onBlur={() => {
const newSettings = {
...settings,
preferences: {
...settings?.preferences,
city: cityQuery,
},
};
scheduleSettingsSave(newSettings);
}}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{citySearching ? <CircularProgress size={16} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
renderOption={(props, opt) => (
<Box component="li" {...props} key={`${opt.name}-${opt.timezone}`}>
<Stack>
<Typography variant="body2">
{opt.name}{opt.region ? `, ${opt.region}` : ''}
</Typography>
{opt.timezone && (
<Typography variant="caption" color="text.secondary">
{opt.timezone}
</Typography>
)}
</Stack>
</Box>
)}
/>
<TextField
label="Часовой пояс"
value={settings?.preferences?.timezone || ''}
fullWidth
disabled
helperText="Заполняется автоматически при выборе города"
/>
</>
)}
</Stack>
</CardContent>
</Card>
{/* Telegram */}
<Card>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Telegram
</Typography>
<TelegramSection onAvatarLoaded={handleTgAvatarLoaded} />
</CardContent>
</Card>
</Stack>
{/* ─── RIGHT COLUMN ─── */}
<Stack spacing={3}>
{/* Notification preferences */}
<Card>
<CardContent>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="subtitle1">Уведомления</Typography>
{notifSaving && <CircularProgress size={16} />}
</Stack>
{settingsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={24} />
</Box>
) : (
<Stack spacing={2}>
{/* Global toggle */}
<FormControlLabel
control={
<Switch
checked={!!notifPrefs?.enabled}
onChange={() => handleNotifChange({ enabled: !notifPrefs?.enabled })}
/>
}
label="Включить уведомления"
/>
{notifPrefs?.enabled && (
<>
<Divider />
{/* Channel toggles */}
<Stack spacing={1}>
<FormControlLabel
control={
<Switch
size="small"
checked={!!notifPrefs?.email_enabled}
onChange={() => handleNotifChange({ email_enabled: !notifPrefs?.email_enabled })}
/>
}
label="Email"
/>
<FormControlLabel
control={
<Switch
size="small"
checked={!!notifPrefs?.telegram_enabled}
onChange={() => handleNotifChange({ telegram_enabled: !notifPrefs?.telegram_enabled })}
/>
}
label="Telegram"
/>
<FormControlLabel
control={
<Switch
size="small"
checked={!!notifPrefs?.in_app_enabled}
onChange={() => handleNotifChange({ in_app_enabled: !notifPrefs?.in_app_enabled })}
/>
}
label="В приложении"
/>
</Stack>
<Divider />
<Typography variant="subtitle2" color="text.secondary">
Настройки по типу уведомлений
</Typography>
<NotificationMatrix
prefs={notifPrefs}
onChange={handleNotifChange}
role={user?.role}
/>
</>
)}
</Stack>
)}
</CardContent>
</Card>
{/* AI homework settings (mentor only) */}
{user?.role === 'mentor' && settings && (
<Card>
<CardContent>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="subtitle1">AI проверка домашних заданий</Typography>
{settingsSaving && <CircularProgress size={16} />}
</Stack>
<Stack spacing={2}>
<FormControlLabel
control={
<Switch
checked={!!settings?.mentor_homework_ai?.ai_trust_draft}
onChange={() => {
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 сохраняет как черновик (нужна ваша проверка)"
/>
<FormControlLabel
control={
<Switch
checked={!!settings?.mentor_homework_ai?.ai_trust_publish}
onChange={() => {
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 публикует результат автоматически"
/>
<Typography variant="caption" color="text.secondary">
Эти опции взаимно исключают друг друга
</Typography>
</Stack>
</CardContent>
</Card>
)}
</Stack>
</Box>
<Snackbar
open={snack.open}
autoHideDuration={3000}
onClose={() => setSnack((prev) => ({ ...prev, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert severity={snack.severity} onClose={() => setSnack((prev) => ({ ...prev, open: false }))}>
{snack.message}
</Alert>
</Snackbar>
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export { AccountPlatformView } from './account-platform-view';

View File

@ -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 (
<Paper sx={{ p: 2, borderRadius: 2, flex: 1, minWidth: 120 }}>
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
<Typography variant="h6" fontWeight={700} mt={0.5}>
{value ?? '—'}
</Typography>
</Paper>
);
}
function DateRangeFilter({ value, onChange, disabled }) {
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru">
<Stack direction="row" spacing={1} alignItems="center">
<DatePicker
label="От"
value={dayjs(value.start_date)}
onChange={(d) => d && onChange({ ...value, start_date: d.format('YYYY-MM-DD') })}
disabled={disabled}
slotProps={{ textField: { size: 'small', sx: { width: 140 } } }}
/>
<DatePicker
label="До"
value={dayjs(value.end_date)}
onChange={(d) => d && onChange({ ...value, end_date: d.format('YYYY-MM-DD') })}
disabled={disabled}
slotProps={{ textField: { size: 'small', sx: { width: 140 } } }}
/>
</Stack>
</LocalizationProvider>
);
}
// ----------------------------------------------------------------------
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 (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={1}>
<Typography variant="h6">Доход</Typography>
<DateRangeFilter value={range} onChange={setRange} disabled={loading} />
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : (
<>
<Stack direction="row" spacing={2} mb={3} flexWrap="wrap">
<StatCard label="Общий доход" value={formatCurrency(data?.summary?.total_income)} />
<StatCard label="Средняя цена" value={formatCurrency(data?.summary?.average_lesson_price)} />
<StatCard label="Занятий" value={data?.summary?.total_lessons} />
</Stack>
{(data?.chart_data ?? []).length > 0 ? (
<ApexChart options={chartOptions} series={series} type="area" height={300} />
) : (
<Typography color="text.secondary" align="center" py={4}>
Нет данных за период
</Typography>
)}
{(data?.top_lessons ?? []).length > 0 && (
<Box mt={3}>
<Typography variant="subtitle1" fontWeight={600} mb={1}>
Топ занятий по доходам
</Typography>
{data.top_lessons.slice(0, 10).map((item, i) => (
<Stack
key={i}
direction="row"
justifyContent="space-between"
py={1}
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<Typography variant="body2">
{i + 1}. {item.lesson_title || item.target_name || 'Занятие'}
</Typography>
<Typography variant="body2" fontWeight={600} color="primary.main">
{formatCurrency(item.total_income)}
</Typography>
</Stack>
))}
</Box>
)}
</>
)}
</Box>
);
}
// ----------------------------------------------------------------------
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 (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={1}>
<Typography variant="h6">Занятия</Typography>
<DateRangeFilter value={range} onChange={setRange} disabled={loading} />
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : (
<>
<Stack direction="row" spacing={2} mb={3} flexWrap="wrap">
<StatCard label="Всего" value={overview?.lessons?.total} />
<StatCard label="Проведено" value={overview?.lessons?.completed} />
<StatCard label="Отменено" value={overview?.lessons?.cancelled} />
</Stack>
{byDay.length > 0 ? (
<ApexChart options={chartOptions} series={series} type="area" height={300} />
) : (
<Typography color="text.secondary" align="center" py={4}>
Нет данных за период
</Typography>
)}
</>
)}
</Box>
);
}
// ----------------------------------------------------------------------
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 (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={1}>
<Typography variant="h6">Успех учеников</Typography>
<DateRangeFilter value={range} onChange={setRange} disabled={loading} />
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : (
<>
<Stack direction="row" spacing={2} mb={3} flexWrap="wrap">
<StatCard label="Средняя оценка" value={grades?.summary?.average_grade ?? overview?.grades?.average} />
<StatCard label="Занятий с оценкой" value={grades?.summary?.graded_lessons} />
<StatCard label="Активных учеников" value={overview?.students?.active} />
</Stack>
{byDay.length > 0 ? (
<ApexChart options={chartOptions} series={series} type="area" height={300} />
) : (
<Typography color="text.secondary" align="center" py={4}>
Нет данных за период
</Typography>
)}
{(students?.students ?? []).length > 0 && (
<Box mt={3}>
<Typography variant="subtitle1" fontWeight={600} mb={1}>
Топ ученики
</Typography>
{students.students.slice(0, 10).map((s, i) => (
<Stack
key={s.id}
direction="row"
justifyContent="space-between"
py={1}
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<Typography variant="body2">{i + 1}. {s.name}</Typography>
<Typography variant="body2" color="text.secondary">
{s.lessons_completed} занятий · ср. {s.average_grade}
</Typography>
</Stack>
))}
</Box>
)}
</>
)}
</Box>
);
}
// ----------------------------------------------------------------------
export function AnalyticsView() {
const { user } = useAuthContext();
const [tab, setTab] = useState(0);
if (user?.role !== 'mentor') {
return (
<DashboardContent>
<Typography color="text.secondary" align="center" mt={8}>
Аналитика доступна только менторам.
</Typography>
</DashboardContent>
);
}
return (
<DashboardContent maxWidth="lg">
<Typography variant="h4" mb={3}>
Аналитика
</Typography>
<Paper sx={{ borderRadius: 2 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tab label="Доход" />
<Tab label="Занятия" />
<Tab label="Ученики" />
</Tabs>
<Box p={3}>
{tab === 0 && <IncomeTab />}
{tab === 1 && <LessonsTab />}
{tab === 2 && <StudentsTab />}
</Box>
</Paper>
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export * from './analytics-view';

View File

@ -1,3 +1,5 @@
export * from './jwt-sign-in-view'; export * from './jwt-sign-in-view';
export * from './jwt-sign-up-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';

View File

@ -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 (
<>
<Stack spacing={1.5} sx={{ mb: 5 }}>
<Typography variant="h5">Forgot your password?</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Enter your email address and we will send you a link to reset your password.
</Typography>
</Stack>
{!!successMsg && (
<Alert severity="success" sx={{ mb: 3 }}>
{successMsg}
</Alert>
)}
{!!errorMsg && (
<Alert severity="error" sx={{ mb: 3 }}>
{errorMsg}
</Alert>
)}
{!successMsg && (
<Form methods={methods} onSubmit={onSubmit}>
<Stack spacing={3}>
<Field.Text name="email" label="Email address" InputLabelProps={{ shrink: true }} />
<LoadingButton
fullWidth
color="inherit"
size="large"
type="submit"
variant="contained"
loading={isSubmitting}
loadingIndicator="Sending..."
>
Send reset link
</LoadingButton>
</Stack>
</Form>
)}
<Link
component={RouterLink}
href={paths.auth.jwt.signIn}
variant="body2"
color="inherit"
sx={{ display: 'block', mt: 3, textAlign: 'center' }}
>
Back to sign in
</Link>
</>
);
}

View File

@ -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 (
<>
<Stack spacing={1.5} sx={{ mb: 5 }}>
<Typography variant="h5">Reset password</Typography>
</Stack>
<Alert severity="error" sx={{ mb: 3 }}>
Reset link is missing. Please follow the link from your email or request a new one.
</Alert>
<Link component={RouterLink} href={paths.auth.jwt.forgotPassword} variant="subtitle2">
Request new reset link
</Link>
</>
);
}
if (successMsg) {
return (
<>
<Stack spacing={1.5} sx={{ mb: 5 }}>
<Typography variant="h5">Reset password</Typography>
</Stack>
<Alert severity="success" sx={{ mb: 3 }}>
{successMsg}
</Alert>
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
Sign in
</Link>
</>
);
}
return (
<>
<Stack spacing={1.5} sx={{ mb: 5 }}>
<Typography variant="h5">Set new password</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Enter your new password below.
</Typography>
</Stack>
{!!errorMsg && (
<Alert severity="error" sx={{ mb: 3 }}>
{errorMsg}
</Alert>
)}
<Form methods={methods} onSubmit={onSubmit}>
<Stack spacing={3}>
<Field.Text
name="newPassword"
label="New password"
placeholder="6+ characters"
type={newPassword.value ? 'text' : 'password'}
InputLabelProps={{ shrink: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={newPassword.onToggle} edge="end">
<Iconify icon={newPassword.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
</IconButton>
</InputAdornment>
),
}}
/>
<Field.Text
name="newPasswordConfirm"
label="Confirm new password"
placeholder="6+ characters"
type={newPasswordConfirm.value ? 'text' : 'password'}
InputLabelProps={{ shrink: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={newPasswordConfirm.onToggle} edge="end">
<Iconify icon={newPasswordConfirm.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
</IconButton>
</InputAdornment>
),
}}
/>
<LoadingButton
fullWidth
color="inherit"
size="large"
type="submit"
variant="contained"
loading={isSubmitting}
loadingIndicator="Saving..."
>
Save new password
</LoadingButton>
</Stack>
</Form>
<Link
component={RouterLink}
href={paths.auth.jwt.signIn}
variant="body2"
color="inherit"
sx={{ display: 'block', mt: 3, textAlign: 'center' }}
>
Back to sign in
</Link>
</>
);
}
// ----------------------------------------------------------------------
export function JwtResetPasswordView() {
return (
<Suspense fallback={<Typography variant="body2">Loading...</Typography>}>
<ResetPasswordContent />
</Suspense>
);
}

View File

@ -50,8 +50,8 @@ export function JwtSignInView() {
const password = useBoolean(); const password = useBoolean();
const defaultValues = { const defaultValues = {
email: 'demo@minimals.cc', email: 'mentor@demo.uchill.online',
password: '@demo1', password: 'demo123456',
}; };
const methods = useForm({ const methods = useForm({
@ -99,7 +99,7 @@ export function JwtSignInView() {
<Stack spacing={1.5}> <Stack spacing={1.5}>
<Link <Link
component={RouterLink} component={RouterLink}
href="#" href={paths.auth.jwt.forgotPassword}
variant="body2" variant="body2"
color="inherit" color="inherit"
sx={{ alignSelf: 'flex-end' }} sx={{ alignSelf: 'flex-end' }}

View File

@ -8,8 +8,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import FormControlLabel from '@mui/material/FormControlLabel';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
@ -27,35 +30,48 @@ import { useAuthContext } from 'src/auth/hooks';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const SignUpSchema = zod.object({ export const SignUpSchema = zod
.object({
firstName: zod.string().min(1, { message: 'First name is required!' }), firstName: zod.string().min(1, { message: 'First name is required!' }),
lastName: zod.string().min(1, { message: 'Last name is required!' }), lastName: zod.string().min(1, { message: 'Last name is required!' }),
email: zod email: zod
.string() .string()
.min(1, { message: 'Email is required!' }) .min(1, { message: 'Email is required!' })
.email({ message: 'Email must be a valid email address!' }), .email({ message: 'Email must be a valid email address!' }),
role: zod.enum(['mentor', 'client', 'parent'], { message: 'Role is required!' }),
city: zod.string().min(1, { message: 'City is required!' }),
password: zod password: zod
.string() .string()
.min(1, { message: 'Password is required!' }) .min(1, { message: 'Password is required!' })
.min(6, { message: 'Password must be at least 6 characters!' }), .min(6, { message: 'Password must be at least 6 characters!' }),
}); passwordConfirm: zod.string().min(1, { message: 'Please confirm your password!' }),
})
.refine((data) => data.password === data.passwordConfirm, {
message: 'Passwords do not match!',
path: ['passwordConfirm'],
});
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function JwtSignUpView() { export function JwtSignUpView() {
const { checkUserSession } = useAuthContext(); const { checkUserSession } = useAuthContext();
const router = useRouter(); const router = useRouter();
const password = useBoolean(); const password = useBoolean();
const passwordConfirm = useBoolean();
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const [consent, setConsent] = useState(false);
const defaultValues = { const defaultValues = {
firstName: 'Hello', firstName: '',
lastName: 'Friend', lastName: '',
email: 'hello@gmail.com', email: '',
password: '@demo1', role: 'client',
city: '',
password: '',
passwordConfirm: '',
}; };
const methods = useForm({ const methods = useForm({
@ -69,19 +85,35 @@ export function JwtSignUpView() {
} = methods; } = methods;
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
if (!consent) {
setErrorMsg('Please agree to the Terms of Service and Privacy Policy.');
return;
}
try { try {
await signUp({ const result = await signUp({
email: data.email, email: data.email,
password: data.password, password: data.password,
passwordConfirm: data.passwordConfirm,
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName, 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(); router.refresh();
} catch (error) { } catch (error) {
console.error(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() {
</Stack> </Stack>
); );
if (successMsg) {
return (
<>
{renderHead}
<Alert severity="success">{successMsg}</Alert>
<Stack sx={{ mt: 3 }}>
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
Back to sign in
</Link>
</Stack>
</>
);
}
const renderForm = ( const renderForm = (
<Stack spacing={3}> <Stack spacing={3}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
@ -110,6 +156,14 @@ export function JwtSignUpView() {
<Field.Text name="email" label="Email address" InputLabelProps={{ shrink: true }} /> <Field.Text name="email" label="Email address" InputLabelProps={{ shrink: true }} />
<Field.Select name="role" label="Role" InputLabelProps={{ shrink: true }}>
<MenuItem value="client">Student</MenuItem>
<MenuItem value="mentor">Mentor</MenuItem>
<MenuItem value="parent">Parent</MenuItem>
</Field.Select>
<Field.Text name="city" label="City (for timezone)" InputLabelProps={{ shrink: true }} />
<Field.Text <Field.Text
name="password" name="password"
label="Password" label="Password"
@ -127,6 +181,39 @@ export function JwtSignUpView() {
}} }}
/> />
<Field.Text
name="passwordConfirm"
label="Confirm password"
placeholder="6+ characters"
type={passwordConfirm.value ? 'text' : 'password'}
InputLabelProps={{ shrink: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={passwordConfirm.onToggle} edge="end">
<Iconify icon={passwordConfirm.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
</IconButton>
</InputAdornment>
),
}}
/>
<FormControlLabel
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />}
label={
<Typography variant="body2">
I agree to the{' '}
<Link underline="always" color="text.primary">
Terms of service
</Link>{' '}
and{' '}
<Link underline="always" color="text.primary">
Privacy policy
</Link>
</Typography>
}
/>
<LoadingButton <LoadingButton
fullWidth fullWidth
color="inherit" color="inherit"
@ -134,35 +221,13 @@ export function JwtSignUpView() {
type="submit" type="submit"
variant="contained" variant="contained"
loading={isSubmitting} loading={isSubmitting}
loadingIndicator="Create account..." loadingIndicator="Creating account..."
> >
Create account Create account
</LoadingButton> </LoadingButton>
</Stack> </Stack>
); );
const renderTerms = (
<Typography
component="div"
sx={{
mt: 3,
textAlign: 'center',
typography: 'caption',
color: 'text.secondary',
}}
>
{'By signing up, I agree to '}
<Link underline="always" color="text.primary">
Terms of service
</Link>
{' and '}
<Link underline="always" color="text.primary">
Privacy policy
</Link>
.
</Typography>
);
return ( return (
<> <>
{renderHead} {renderHead}
@ -176,8 +241,6 @@ export function JwtSignUpView() {
<Form methods={methods} onSubmit={onSubmit}> <Form methods={methods} onSubmit={onSubmit}>
{renderForm} {renderForm}
</Form> </Form>
{renderTerms}
</> </>
); );
} }

View File

@ -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 (
<>
<Stack spacing={1.5} sx={{ mb: 5 }}>
<Typography variant="h5">Email verification</Typography>
</Stack>
{status === 'loading' && (
<Stack alignItems="center" sx={{ py: 4 }}>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
Verifying your email...
</Typography>
</Stack>
)}
{status === 'success' && (
<>
<Alert severity="success" sx={{ mb: 3 }}>
{message}
</Alert>
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
Sign in to your account
</Link>
</>
)}
{status === 'error' && (
<>
<Alert severity="error" sx={{ mb: 3 }}>
{message}
</Alert>
<Stack spacing={1}>
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
Back to sign in
</Link>
<Link component={RouterLink} href={paths.auth.jwt.signUp} variant="body2" color="text.secondary">
Create a new account
</Link>
</Stack>
</>
)}
</>
);
}
// ----------------------------------------------------------------------
export function JwtVerifyEmailView() {
return (
<Suspense fallback={<Typography variant="body2">Loading...</Typography>}>
<VerifyEmailContent />
</Suspense>
);
}

View File

@ -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 <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
}
// ----------------------------------------------------------------------
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 (
<Card sx={{ borderRadius: 2, height: '100%' }}>
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
{/* Preview area */}
<Box
sx={{
height: 140,
bgcolor: 'primary.lighter',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
<Iconify icon="solar:pen-new-square-bold-duotone" width={48} sx={{ color: 'primary.main', opacity: 0.4 }} />
{board.elements_count > 0 && (
<Box
sx={{
position: 'absolute',
bottom: 8,
right: 8,
bgcolor: 'background.paper',
borderRadius: 1,
px: 1,
py: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
{board.elements_count} элем.
</Typography>
</Box>
)}
</Box>
<CardContent>
<Typography variant="subtitle2" noWrap mb={0.5}>
{board.title || 'Без названия'}
</Typography>
<Stack direction="row" alignItems="center" spacing={1}>
<Avatar sx={{ width: 24, height: 24, fontSize: 11, bgcolor: 'primary.main' }}>
{otherInitials}
</Avatar>
<Typography variant="caption" color="text.secondary" noWrap>
{isMentor ? 'Ученик: ' : 'Ментор: '}{otherName}
</Typography>
</Stack>
{lastEdited && (
<Typography variant="caption" color="text.disabled" display="block" mt={0.5}>
Изменено {lastEdited}
</Typography>
)}
</CardContent>
</CardActionArea>
</Card>
);
}
// ----------------------------------------------------------------------
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
const allBoards = [...myBoards, ...sharedBoards];
const uniqueBoards = allBoards.filter((b, i, arr) => arr.findIndex((x) => x.board_id === b.board_id) === i);
return (
<Box>
{!excalidrawAvailable && (
<Box sx={{ mb: 3, p: 2, bgcolor: 'warning.lighter', borderRadius: 2 }}>
<Typography variant="body2" color="warning.dark">
Excalidraw не настроен. Укажите NEXT_PUBLIC_EXCALIDRAW_URL или NEXT_PUBLIC_EXCALIDRAW_PATH в .env
</Typography>
</Box>
)}
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3} flexWrap="wrap" gap={2}>
<Typography variant="h4">Доски</Typography>
{user?.role === 'mentor' && students.length > 0 && (
<Stack direction="row" spacing={1} flexWrap="wrap">
{students.slice(0, 6).map((s) => {
const name = getUserName(s.user || s);
const initials = getUserInitials(s.user || s);
return (
<Tooltip key={s.id} title={`Открыть доску с ${name}`}>
<Button
variant="outlined"
size="small"
startIcon={<Avatar sx={{ width: 20, height: 20, fontSize: 10 }}>{initials}</Avatar>}
onClick={() => handleCreateWithStudent(s.user || s)}
disabled={creating}
>
{name.split(' ')[0]}
</Button>
</Tooltip>
);
})}
</Stack>
)}
</Stack>
{uniqueBoards.length === 0 ? (
<Box
sx={{
py: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
color: 'text.secondary',
}}
>
<Iconify icon="solar:pen-new-square-linear" width={64} sx={{ opacity: 0.3 }} />
<Typography variant="body1">Досок пока нет</Typography>
{user?.role === 'mentor' && (
<Typography variant="body2">
Нажмите на имя ученика выше, чтобы открыть совместную доску
</Typography>
)}
</Box>
) : (
<Grid container spacing={2.5}>
{uniqueBoards.map((board) => (
<Grid key={board.board_id} xs={12} sm={6} md={4} lg={3}>
<BoardCard board={board} currentUser={user} onClick={() => onOpen(board.board_id)} />
</Grid>
))}
</Grid>
)}
</Box>
);
}
// ----------------------------------------------------------------------
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 (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: {
xs: 'calc(100dvh - var(--layout-header-mobile-height))',
lg: 'calc(100dvh - var(--layout-header-desktop-height))',
},
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<IconButton size="small" onClick={handleBack}>
<Iconify icon="solar:arrow-left-linear" />
</IconButton>
<Typography variant="h6">Интерактивная доска</Typography>
<Typography variant="body2" color="text.secondary">#{boardId}</Typography>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<WhiteboardIframe boardId={boardId} user={user} />
</Box>
</Box>
);
}
return (
<DashboardContent maxWidth="xl">
<BoardListView onOpen={handleOpen} />
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export { BoardView } from './board-view';

View File

@ -1,39 +1,45 @@
import 'dayjs/locale/ru';
import dayjs from 'dayjs';
import { z as zod } from 'zod'; import { z as zod } from 'zod';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMemo, useCallback } from 'react'; import { useMemo, useState, useCallback } from 'react';
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
dayjs.locale('ru'); import { useRouter } from 'next/navigation';
// ----------------------------------------------------------------------
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import MenuItem from '@mui/material/MenuItem'; 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 IconButton from '@mui/material/IconButton';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import InputAdornment from '@mui/material/InputAdornment'; 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 { useGetStudents, useGetSubjects } from 'src/actions/calendar';
import { fIsAfter } from 'src/utils/format-time';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
import { Form, Field } from 'src/components/hook-form'; import { Form, Field } from 'src/components/hook-form';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
dayjs.locale('ru');
// ----------------------------------------------------------------------
const DURATION_OPTIONS = [ const DURATION_OPTIONS = [
{ value: 30, label: '30 минут' }, { value: 30, label: '30 минут' },
{ value: 45, label: '45 минут' }, { value: 45, label: '45 минут' },
@ -51,8 +57,10 @@ export function CalendarForm({
onUpdateEvent, onUpdateEvent,
onDeleteEvent, onDeleteEvent,
}) { }) {
const router = useRouter();
const { students } = useGetStudents(); const { students } = useGetStudents();
const { subjects } = useGetSubjects(); const { subjects } = useGetSubjects();
const [joiningVideo, setJoiningVideo] = useState(false);
const EventSchema = zod.object({ const EventSchema = zod.object({
client: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'), 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 displayTitle = `${subjectName} ${mentorFullName} - ${studentFullName}${lessonNumber}`.trim();
const payload = { const payload = {
title: displayTitle, // Добавляем обязательное поле title title: displayTitle,
client: data.client, client: data.client,
subject: data.subject, subject: data.subject,
description: data.description, description: data.description,
start_time: startTime.toISOString(), start_time: startTime.toISOString(),
end_time: endTime.toISOString(), duration: data.duration,
price: data.price, price: data.price,
is_recurring: data.is_recurring, 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 () => { const onDelete = useCallback(async () => {
try { try {
await onDeleteEvent(`${currentEvent?.id}`); await onDeleteEvent(`${currentEvent?.id}`);
@ -270,6 +293,18 @@ export function CalendarForm({
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
{currentEvent?.id && currentEvent?.extendedProps?.status !== 'completed' && (
<LoadingButton
variant="soft"
color="success"
loading={joiningVideo}
onClick={onJoinVideo}
startIcon={<Iconify icon="solar:videocamera-record-bold" />}
>
Войти
</LoadingButton>
)}
<Button variant="outlined" color="inherit" onClick={onClose}> <Button variant="outlined" color="inherit" onClick={onClose}>
Отмена Отмена
</Button> </Button>

View File

@ -74,12 +74,6 @@ export function CalendarToolbar({
<Button size="small" color="error" variant="contained" onClick={onToday}> <Button size="small" color="error" variant="contained" onClick={onToday}>
Сегодня Сегодня
</Button> </Button>
<IconButton onClick={onOpenFilters}>
<Badge color="error" variant="dot" invisible={!canReset}>
<Iconify icon="ic:round-filter-list" />
</Badge>
</IconButton>
</Stack> </Stack>
{loading && ( {loading && (

View File

@ -17,6 +17,7 @@ import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import { useAuthContext } from 'src/auth/hooks';
import { useBoolean } from 'src/hooks/use-boolean'; import { useBoolean } from 'src/hooks/use-boolean';
import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar'; 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 { CalendarForm } from '../calendar-form';
import { StyledCalendar } from '../styles'; import { StyledCalendar } from '../styles';
import { CalendarToolbar } from '../calendar-toolbar'; import { CalendarToolbar } from '../calendar-toolbar';
import { CalendarFilters } from '../calendar-filters';
import { useCalendar } from '../hooks/use-calendar'; import { useCalendar } from '../hooks/use-calendar';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function CalendarView() { export function CalendarView() {
const settings = useSettingsContext(); 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 { const { events, eventsLoading } = useGetEvents(date);
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;
useEffect(() => { useEffect(() => {
onInitialView(); onInitialView();
}, [onInitialView]); }, [onInitialView]);
const handleFilters = useCallback((name, value) => { const handleCreateEvent = useCallback(
setFilters((prevState) => ({ (eventData) => createEvent(eventData, date),
...prevState, [date]
[name]: value, );
}));
}, []);
const handleResetFilters = useCallback(() => { const handleUpdateEvent = useCallback(
setFilters({ (eventData) => updateEvent(eventData, date),
colors: [], [date]
startDate: null, );
endDate: null,
});
}, []);
const dataPrepared = events.filter((event) => { const handleDeleteEvent = useCallback(
const { colors, startDate, endDate } = filters; (eventId, deleteAllFuture) => deleteEvent(eventId, deleteAllFuture, date),
[date]
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;
});
return ( return (
<> <>
<Container maxWidth={settings.themeStretch ? false : 'xl'}> <Container maxWidth={settings.themeStretch ? false : 'xl'}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: { xs: 3, md: 5 } }}> <Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: { xs: 3, md: 5 } }}
>
<Stack spacing={1}> <Stack spacing={1}>
<Typography variant="h4">Расписание</Typography> <Typography variant="h4">Расписание</Typography>
<CustomBreadcrumbs <CustomBreadcrumbs
@ -117,6 +81,7 @@ export function CalendarView() {
/> />
</Stack> </Stack>
{isMentor && (
<Button <Button
variant="contained" variant="contained"
startIcon={<Iconify icon="mingcute:add-line" />} startIcon={<Iconify icon="mingcute:add-line" />}
@ -124,6 +89,7 @@ export function CalendarView() {
> >
Новое занятие Новое занятие
</Button> </Button>
)}
</Stack> </Stack>
<Card sx={{ position: 'relative' }}> <Card sx={{ position: 'relative' }}>
@ -136,16 +102,15 @@ export function CalendarView() {
onPrevDate={onDatePrev} onPrevDate={onDatePrev}
onToday={onDateToday} onToday={onDateToday}
onChangeView={onChangeView} onChangeView={onChangeView}
onOpenFilters={openFilters.onTrue}
/> />
<FullCalendar <FullCalendar
weekends weekends
editable editable={isMentor}
droppable droppable={isMentor}
selectable selectable={isMentor}
rerenderEvents rerenderEvents
events={dataPrepared} events={events}
ref={calendarRef} ref={calendarRef}
initialDate={date} initialDate={date}
initialView={view} initialView={view}
@ -154,18 +119,12 @@ export function CalendarView() {
headerToolbar={false} headerToolbar={false}
allDayMaintainDuration allDayMaintainDuration
eventResizableFromStart eventResizableFromStart
displayEventTime={false} // Скрываем дефолтное время, так как мы добавили его в title displayEventTime={false}
select={onSelectRange} select={isMentor ? onSelectRange : undefined}
eventClick={onClickEvent} eventClick={onClickEvent}
eventDrop={onDropEvent} eventDrop={isMentor ? onDropEvent : undefined}
eventResize={onResizeEvent} eventResize={isMentor ? onResizeEvent : undefined}
plugins={[ plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]}
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
listPlugin,
timelinePlugin,
]}
locale={ruLocale} locale={ruLocale}
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
@ -174,27 +133,17 @@ export function CalendarView() {
</Card> </Card>
</Container> </Container>
{isMentor && (
<CalendarForm <CalendarForm
currentEvent={events.find((event) => event.id === selectEventId)} currentEvent={events.find((event) => event.id === selectEventId)}
range={selectedRange} range={selectedRange}
open={openForm} open={openForm}
onClose={onCloseForm} onClose={onCloseForm}
onCreateEvent={createEvent} onCreateEvent={handleCreateEvent}
onUpdateEvent={updateEvent} onUpdateEvent={handleUpdateEvent}
onDeleteEvent={deleteEvent} onDeleteEvent={handleDeleteEvent}
/>
<CalendarFilters
open={openFilters.value}
onClose={openFilters.onFalse}
filters={filters}
onFilters={handleFilters}
canReset={!!filters.colors.length || (!!filters.startDate && !!filters.endDate)}
onResetFilters={handleResetFilters}
dateError={dateError}
events={events}
onClickEvent={onClickEventInFilters}
/> />
)}
</> </>
); );
} }

View File

@ -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 (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>Новый чат</DialogTitle>
<DialogContent>
<TextField
label="Поиск пользователя"
value={query}
onChange={(e) => setQuery(e.target.value)}
fullWidth
autoFocus
sx={{ mt: 1 }}
InputProps={{
endAdornment: searching ? (
<InputAdornment position="end">
<CircularProgress size={18} />
</InputAdornment>
) : null,
}}
/>
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
<List sx={{ mt: 1 }}>
{results.map((u) => {
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || '';
return (
<ListItemButton
key={u.id}
onClick={() => handleCreate(u.id)}
disabled={creating}
sx={{ borderRadius: 1 }}
>
<Avatar sx={{ mr: 1.5, width: 36, height: 36 }}>{getInitials(name)}</Avatar>
<ListItemText primary={name} secondary={u.email} />
</ListItemButton>
);
})}
{!searching && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ px: 2, py: 1 }}>
Пользователи не найдены
</Typography>
)}
</List>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Отмена</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
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 (
<Box
sx={{
width: { xs: '100%', md: 300 },
flexShrink: 0,
borderRight: '1px solid',
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Stack direction="row" spacing={1} sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<TextField
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Поиск"
size="small"
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Iconify icon="eva:search-outline" width={18} />
</InputAdornment>
),
}}
/>
<IconButton onClick={onNew} color="primary" size="small">
<Iconify icon="eva:edit-outline" width={22} />
</IconButton>
</Stack>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : filtered.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
{q ? 'Не найдено' : 'Нет чатов'}
</Typography>
) : (
<List disablePadding>
{filtered.map((chat) => {
const selected = !!selectedUuid && chat.uuid === selectedUuid;
return (
<ListItemButton
key={chat.uuid || chat.id}
selected={selected}
onClick={() => onSelect(chat)}
sx={{ py: 1.25, px: 1.5 }}
>
<Avatar sx={{ mr: 1.5, width: 38, height: 38 }}>
{getInitials(chat.participant_name)}
</Avatar>
<ListItemText
primary={
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="subtitle2" noWrap sx={{ maxWidth: 140 }}>
{chat.participant_name || 'Чат'}
</Typography>
{!!chat.unread_count && (
<Badge
badgeContent={chat.unread_count}
color="primary"
sx={{ '& .MuiBadge-badge': { fontSize: 10, minWidth: 16, height: 16 } }}
/>
)}
</Stack>
}
secondary={
<Typography variant="caption" noWrap color="text.secondary">
{stripHtml(chat.last_message || '')}
</Typography>
}
primaryTypographyProps={{ component: 'div' }}
secondaryTypographyProps={{ component: 'div' }}
/>
</ListItemButton>
);
})}
</List>
)}
</Box>
</Box>
);
}
// ----------------------------------------------------------------------
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 (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.secondary',
}}
>
<Typography>Выберите чат из списка</Typography>
</Box>
);
}
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 (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{/* Header */}
<Stack
direction="row"
alignItems="center"
spacing={1.5}
sx={{ px: 2, py: 1.5, borderBottom: '1px solid', borderColor: 'divider', flexShrink: 0 }}
>
{onBack && (
<IconButton onClick={onBack} size="small" sx={{ display: { md: 'none' } }}>
<Iconify icon="eva:arrow-back-outline" />
</IconButton>
)}
<Avatar sx={{ width: 38, height: 38 }}>{getInitials(chat.participant_name)}</Avatar>
<Box>
<Typography variant="subtitle2">{chat.participant_name || 'Чат'}</Typography>
{chat.other_is_online && (
<Typography variant="caption" color="success.main">
Онлайн
</Typography>
)}
</Box>
</Stack>
{/* Messages */}
<Box
ref={listRef}
sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}
onWheel={(e) => {
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 && (
<Typography variant="caption" color="text.secondary" textAlign="center">
Загрузка
</Typography>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : (
grouped.map((item) => {
if (item.type === 'day') {
return (
<Box key={item.key} sx={{ alignSelf: 'center', my: 0.5 }}>
<Typography
variant="caption"
sx={{
px: 1.5,
py: 0.4,
borderRadius: 999,
bgcolor: 'background.neutral',
color: 'text.secondary',
}}
>
{item.label}
</Typography>
</Box>
);
}
const { msg, isMine, isSystem } = item;
const msgUuid = msg.uuid ? String(msg.uuid) : null;
return (
<Box
key={item.key}
data-message-uuid={msgUuid || undefined}
data-is-mine={String(isMine)}
sx={{
alignSelf: isSystem ? 'center' : isMine ? 'flex-end' : 'flex-start',
maxWidth: isSystem ? '85%' : '70%',
px: 1.5,
py: 0.75,
borderRadius: 2,
bgcolor: isSystem
? 'action.hover'
: isMine
? 'primary.main'
: 'background.paper',
color: isSystem ? 'text.secondary' : isMine ? 'primary.contrastText' : 'text.primary',
border: '1px solid',
borderColor: isSystem ? 'divider' : isMine ? 'transparent' : 'divider',
boxShadow: isMine ? 0 : 1,
}}
>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{stripHtml(msg.content || '')}
</Typography>
<Typography
variant="caption"
sx={{ display: 'block', textAlign: 'right', opacity: 0.6, mt: 0.25 }}
>
{formatTime(msg.created_at)}
</Typography>
</Box>
);
})
)}
</Box>
{/* Input */}
<Divider />
<Stack direction="row" spacing={1} sx={{ p: 1.5, flexShrink: 0 }}>
<TextField
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Сообщение…"
fullWidth
multiline
minRows={1}
maxRows={4}
size="small"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<IconButton
onClick={handleSend}
disabled={!text.trim() || sending}
color="primary"
sx={{ alignSelf: 'flex-end' }}
>
{sending ? <CircularProgress size={20} /> : <Iconify icon="eva:paper-plane-outline" />}
</IconButton>
</Stack>
</Box>
);
}
// ----------------------------------------------------------------------
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 (
<DashboardContent sx={{ p: 0, display: 'flex', flexDirection: 'column', flex: 1 }} maxWidth={false} disablePadding>
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<CustomBreadcrumbs
heading="Чат"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Чат' }]}
/>
</Box>
{error && (
<Alert severity="error" sx={{ mx: 3, mt: 2 }}>
{error}
</Alert>
)}
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, height: 'calc(100vh - 180px)' }}>
<Box sx={{ display: { xs: mobileShowWindow ? 'none' : 'flex', md: 'flex' }, height: '100%' }}>
<ChatList
chats={chats}
selectedUuid={selectedChat?.uuid}
onSelect={setSelectedChat}
onNew={() => setNewChatOpen(true)}
loading={loading}
/>
</Box>
<Box
sx={{
flex: 1,
display: { xs: mobileShowWindow ? 'flex' : 'none', md: 'flex' },
flexDirection: 'column',
minHeight: 0,
}}
>
<ChatWindow
chat={selectedChat}
currentUserId={user?.id || null}
onBack={() => setSelectedChat(null)}
/>
</Box>
</Box>
<NewChatDialog
open={newChatOpen}
onClose={() => setNewChatOpen(false)}
onCreated={handleChatCreated}
/>
</DashboardContent>
);
}

View File

@ -1 +1,2 @@
export * from './chat-view'; export * from './chat-view';
export { ChatPlatformView } from './chat-platform-view';

View File

@ -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 (
<Card variant="outlined" sx={{ flex: 1, minWidth: 140 }}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${color}.lighter`,
}}
>
<Iconify icon={icon} width={22} color={`${color}.main`} />
</Box>
<Typography variant="h5" fontWeight="bold">
{value ?? 0}
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
</CardContent>
</Card>
);
}
// ----------------------------------------------------------------------
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 (
<DashboardContent>
<CustomBreadcrumbs
heading="Прогресс ребёнка"
links={[
{ name: 'Главная', href: paths.dashboard.root },
{ name: 'Мои дети', href: paths.dashboard.children },
{ name: 'Прогресс' },
]}
sx={{ mb: 3 }}
/>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="body1" color="text.secondary">
Выберите ребёнка для просмотра прогресса
</Typography>
<Button
variant="outlined"
sx={{ mt: 2 }}
onClick={() => router.push(paths.dashboard.children)}
>
К списку детей
</Button>
</Box>
</DashboardContent>
);
}
return (
<DashboardContent>
<CustomBreadcrumbs
heading="Прогресс ребёнка"
links={[
{ name: 'Главная', href: paths.dashboard.root },
{ name: 'Мои дети', href: paths.dashboard.children },
{ name: 'Прогресс' },
]}
sx={{ mb: 3 }}
/>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : progress ? (
<Stack direction="row" flexWrap="wrap" gap={2}>
<StatCard
label="Завершено занятий"
value={progress.completed_lessons}
icon="eva:checkmark-circle-outline"
color="success"
/>
<StatCard
label="Выполнено заданий"
value={progress.completed_homework}
icon="eva:book-outline"
color="primary"
/>
{progress.attendance_rate !== undefined && (
<StatCard
label="Посещаемость"
value={`${Math.round(progress.attendance_rate)}%`}
icon="eva:calendar-outline"
color="info"
/>
)}
{progress.avg_grade !== undefined && progress.avg_grade > 0 && (
<StatCard
label="Средняя оценка"
value={progress.avg_grade}
icon="eva:star-outline"
color="warning"
/>
)}
</Stack>
) : (
<Typography variant="body1" color="text.secondary">
Нет данных о прогрессе
</Typography>
)}
</DashboardContent>
);
}

View File

@ -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 (
<DashboardContent>
<CustomBreadcrumbs
heading="Мои дети"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Мои дети' }]}
sx={{ mb: 3 }}
/>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : children.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Iconify icon="eva:people-outline" width={64} color="text.disabled" />
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
Нет привязанных детей
</Typography>
</Box>
) : (
<Grid container spacing={2}>
{children.map((child) => {
const name = `${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email;
return (
<Grid key={child.id} item xs={12} sm={6} md={4}>
<Card variant="outlined">
<CardContent>
<Stack direction="row" spacing={2} alignItems="center">
<Avatar sx={{ width: 48, height: 48, bgcolor: 'primary.lighter', color: 'primary.main' }}>
{name[0]?.toUpperCase()}
</Avatar>
<Box>
<Typography variant="subtitle1">{name}</Typography>
<Typography variant="body2" color="text.secondary">
{child.email}
</Typography>
</Box>
</Stack>
</CardContent>
<CardActions sx={{ px: 2, pb: 2 }}>
<Button
variant="contained"
fullWidth
startIcon={<Iconify icon="eva:trending-up-outline" />}
onClick={() => router.push(`${paths.dashboard.childrenProgress}?child=${child.id}`)}
>
Прогресс
</Button>
</CardActions>
</Card>
</Grid>
);
})}
</Grid>
)}
</DashboardContent>
);
}

View File

@ -0,0 +1,2 @@
export { ChildrenView } from './children-view';
export { ChildrenProgressView } from './children-progress-view';

View File

@ -1,5 +1,4 @@
import { useState } from 'react'; import { lazy, Suspense, useState } from 'react';
import dynamic from 'next/dynamic';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Skeleton from '@mui/material/Skeleton'; 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), { const Map = lazy(() => import('src/components/map').then((mod) => ({ default: mod.Map })));
loading: () => (
<Skeleton
variant="rectangular"
sx={{ top: 0, left: 0, width: 1, height: 1, position: 'absolute' }}
/>
),
});
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -38,6 +30,14 @@ export function ContactMap({ contacts }) {
position: 'relative', position: 'relative',
height: { xs: 320, md: 560 }, height: { xs: 320, md: 560 },
}} }}
>
<Suspense
fallback={
<Skeleton
variant="rectangular"
sx={{ top: 0, left: 0, width: 1, height: 1, position: 'absolute' }}
/>
}
> >
<Map <Map
initialViewState={{ latitude: 12, longitude: 42, zoom: 2 }} initialViewState={{ latitude: 12, longitude: 42, zoom: 2 }}
@ -82,6 +82,7 @@ export function ContactMap({ contacts }) {
</MapPopup> </MapPopup>
)} )}
</Map> </Map>
</Suspense>
</Box> </Box>
); );
} }

View File

@ -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 (
<Drawer anchor="right" open={open} onClose={loading ? undefined : onClose} PaperProps={{ sx: { width: { xs: '100%', sm: 480 } } }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" px={3} py={2} borderBottom="1px solid" borderColor="divider">
<Box>
<Typography variant="h6">Обратная связь</Typography>
{lesson && (
<Typography variant="caption" color="text.secondary">
{lesson.title} {getSubjectName(lesson)}
</Typography>
)}
</Box>
<IconButton onClick={onClose} disabled={loading}>
<Iconify icon="mingcute:close-line" />
</IconButton>
</Stack>
{lesson && (
<Box component="form" onSubmit={handleSubmit} sx={{ p: 3, overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 2, bgcolor: 'primary.lighter' }}>
<Stack direction="row" flexWrap="wrap" gap={2}>
<Box>
<Typography variant="caption" color="text.secondary">Студент</Typography>
<Typography variant="body2" fontWeight={500}>{getClientName(lesson)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Дата</Typography>
<Typography variant="body2" fontWeight={500}>{formatDate(lesson.start_time)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Время</Typography>
<Typography variant="body2" fontWeight={500}>{formatTime(lesson.start_time)} {formatTime(lesson.end_time)}</Typography>
</Box>
</Stack>
</Paper>
<Typography variant="subtitle2">Оценки</Typography>
<Stack direction="row" spacing={2}>
<TextField
label="Оценка за занятие (15)"
type="number"
inputProps={{ min: 1, max: 5 }}
value={form.mentor_grade}
onChange={(e) => setForm((p) => ({ ...p, mentor_grade: e.target.value }))}
disabled={loading}
size="small"
fullWidth
/>
<TextField
label="Оценка в школе (15)"
type="number"
inputProps={{ min: 1, max: 5 }}
value={form.school_grade}
onChange={(e) => setForm((p) => ({ ...p, school_grade: e.target.value }))}
disabled={loading}
size="small"
fullWidth
/>
</Stack>
<TextField
label="Комментарий к занятию"
multiline
rows={4}
value={form.mentor_notes}
onChange={(e) => setForm((p) => ({ ...p, mentor_notes: e.target.value }))}
disabled={loading}
placeholder="Что прошли на занятии, успехи студента, рекомендации..."
/>
{error && (
<Typography color="error" variant="body2">
{error}
</Typography>
)}
<Stack direction="row" spacing={1.5} mt="auto">
<Button variant="outlined" color="inherit" onClick={onClose} disabled={loading} fullWidth>
Отмена
</Button>
<Button type="submit" variant="contained" disabled={loading} fullWidth>
{loading ? 'Сохранение...' : 'Сохранить'}
</Button>
</Stack>
</Box>
)}
</Drawer>
);
}
// ----------------------------------------------------------------------
function LessonCard({ lesson, onFill }) {
const hasFeedback = !!(lesson.mentor_grade || lesson.school_grade || lesson.mentor_notes?.trim());
return (
<Paper
sx={{
p: 2,
borderRadius: 2,
cursor: 'pointer',
transition: 'box-shadow 0.2s',
'&:hover': { boxShadow: 4 },
}}
onClick={onFill}
>
<Chip label={getSubjectName(lesson)} size="small" color="primary" variant="soft" sx={{ mb: 1 }} />
<Typography variant="subtitle2" mb={0.5}>
{lesson.title}
</Typography>
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center" gap={0.5} mb={0.5}>
<Iconify icon="solar:user-linear" width={14} />
{getClientName(lesson)}
</Typography>
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center" gap={0.5} mb={0.5}>
<Iconify icon="solar:calendar-linear" width={14} />
{formatDate(lesson.start_time)}
</Typography>
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center" gap={0.5} mb={1.5}>
<Iconify icon="solar:clock-circle-linear" width={14} />
{formatTime(lesson.start_time)} {formatTime(lesson.end_time)}
</Typography>
{lesson.mentor_grade != null && (
<Typography variant="caption" color="primary.main" fontWeight={600} display="block" mb={1}>
Оценка: {lesson.mentor_grade}/5
</Typography>
)}
<Button
variant={hasFeedback ? 'soft' : 'contained'}
size="small"
fullWidth
onClick={(e) => { e.stopPropagation(); onFill(); }}
>
{hasFeedback ? 'Редактировать' : 'Заполнить'}
</Button>
</Paper>
);
}
// ----------------------------------------------------------------------
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 (
<DashboardContent>
<Typography color="text.secondary" align="center" mt={8}>
Страница доступна только менторам.
</Typography>
</DashboardContent>
);
}
return (
<DashboardContent maxWidth="xl">
<Typography variant="h4" mb={3}>
Обратная связь
</Typography>
{error && (
<Typography color="error" mb={2}>
{error}
</Typography>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems="flex-start">
{/* Ожидают */}
<Box flex={1} minWidth={0}>
<Typography variant="subtitle1" fontWeight={700} mb={2}>
Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''}
</Typography>
<Stack spacing={2}>
{todoLessons.map((l) => (
<LessonCard
key={l.id}
lesson={l}
onFill={() => { setSelected(l); setDrawerOpen(true); }}
/>
))}
{todoLessons.length === 0 && (
<Typography color="text.secondary" variant="body2">
Нет занятий, ожидающих обратной связи
</Typography>
)}
</Stack>
</Box>
<Divider orientation="vertical" flexItem sx={{ display: { xs: 'none', md: 'block' } }} />
{/* Заполнено */}
<Box flex={1} minWidth={0}>
<Typography variant="subtitle1" fontWeight={700} mb={2}>
Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''}
</Typography>
<Stack spacing={2}>
{doneLessons.map((l) => (
<LessonCard
key={l.id}
lesson={l}
onFill={() => { setSelected(l); setDrawerOpen(true); }}
/>
))}
{doneLessons.length === 0 && (
<Typography color="text.secondary" variant="body2">
Нет завершённых занятий
</Typography>
)}
</Stack>
</Box>
</Stack>
)}
<FeedbackDrawer
open={drawerOpen}
lesson={selected}
onClose={() => setDrawerOpen(false)}
onSuccess={load}
/>
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export * from './feedback-view';

View File

@ -0,0 +1 @@
export { MyProgressView } from './my-progress-view';

View File

@ -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 (
<Card variant="outlined" sx={{ flex: 1, minWidth: 120 }}>
<CardContent sx={{ pb: '12px !important' }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${color}.lighter`,
}}
>
<Iconify icon={icon} width={20} color={`${color}.main`} />
</Box>
<Typography variant="h5" fontWeight="bold">
{value}
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
{sub && (
<Typography variant="caption" color="text.disabled">
{sub}
</Typography>
)}
</CardContent>
</Card>
);
}
// ----------------------------------------------------------------------
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 (
<DashboardContent>
<CustomBreadcrumbs
heading="Мой прогресс"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Прогресс' }]}
sx={{ mb: 3 }}
/>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Controls */}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }} flexWrap="wrap">
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Предмет</InputLabel>
<Select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
label="Предмет"
disabled={subjects.length === 0}
>
<MenuItem value="">Все предметы</MenuItem>
{subjects.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</FormControl>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" color="text.secondary">
От:
</Typography>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }}
/>
<Typography variant="body2" color="text.secondary">
До:
</Typography>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }}
/>
</Stack>
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={3}>
{/* Stats row */}
<Stack direction="row" flexWrap="wrap" gap={2}>
<StatTile
label="Занятий проведено"
value={stats.completed}
sub={`из ${stats.total}`}
icon="eva:checkmark-circle-outline"
color="success"
/>
<StatTile
label="Посещаемость"
value={`${stats.attendanceRate}%`}
icon="eva:calendar-outline"
color="primary"
/>
<StatTile
label="Средняя оценка"
value={stats.avgGrade || '—'}
icon="eva:star-outline"
color="warning"
/>
</Stack>
{/* Attendance chart */}
{typeof window !== 'undefined' && chartData.categories.length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Посещаемость
</Typography>
<Chart
type="bar"
height={220}
series={chartData.attendanceSeries}
options={{ ...chartOptionsBase, colors: ['#5BE49B'] }}
/>
</CardContent>
</Card>
)}
{/* Grades chart */}
{typeof window !== 'undefined' && chartData.categories.length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Оценки
</Typography>
<Chart
type="line"
height={220}
series={chartData.gradesSeries}
options={{
...chartOptionsBase,
colors: ['#00A76F'],
yaxis: { min: 1, max: 5 },
markers: { size: 4 },
}}
/>
</CardContent>
</Card>
)}
{lessons.length === 0 && (
<Typography variant="body1" color="text.secondary">
Нет занятий за выбранный период
</Typography>
)}
</Stack>
)}
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export * from './overview-client-view';

View File

@ -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 (
<Stack
direction="row"
spacing={2}
alignItems="center"
sx={{
py: 1.5,
px: 2,
borderRadius: 1.5,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Avatar sx={{ width: 40, height: 40, bgcolor: 'primary.main', fontSize: 14 }}>
{mentorName[0]?.toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{lesson.title || lesson.subject || 'Занятие'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{mentorName}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{formatDateTime(lesson.start_time)}
</Typography>
</Stack>
);
}
// ----------------------------------------------------------------------
function HomeworkItem({ homework }) {
const statusColor = {
pending: 'warning',
submitted: 'info',
reviewed: 'success',
completed: 'success',
}[homework.status] || 'default';
return (
<Stack
direction="row"
spacing={2}
alignItems="center"
sx={{
py: 1.5,
px: 2,
borderRadius: 1.5,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
bgcolor: `${statusColor}.main`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
opacity: 0.8,
}}
>
<Iconify icon="solar:document-bold" width={20} sx={{ color: 'white' }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{homework.title}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{homework.subject || 'Предмет не указан'}
</Typography>
</Box>
{homework.grade != null && (
<Typography variant="caption" sx={{ color: `${statusColor}.main`, fontWeight: 600, flexShrink: 0 }}>
{homework.grade}/5
</Typography>
)}
</Stack>
);
}
// ----------------------------------------------------------------------
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 (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
return (
<DashboardContent
maxWidth={false}
disablePadding
sx={{ borderTop: { lg: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}` } }}
>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: { xs: 'column', lg: 'row' } }}>
{/* LEFT */}
<Box
sx={{
gap: 3,
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
px: { xs: 2, sm: 3, xl: 5 },
py: 3,
borderRight: { lg: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}` },
}}
>
{/* Greeting */}
<Box>
<Typography variant="h4" sx={{ mb: 0.5 }}>
Привет, {displayName}! 👋
</Typography>
<Typography sx={{ color: 'text.secondary' }}>
Твой прогресс и ближайшие занятия.
</Typography>
</Box>
{/* Stat widgets */}
<Box
sx={{
gap: 3,
display: 'grid',
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
}}
>
<CourseWidgetSummary
title="Занятий всего"
total={Number(stats?.total_lessons || 0)}
icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-progress.svg`}
color="primary"
/>
<CourseWidgetSummary
title="Пройдено"
total={Number(stats?.completed_lessons || 0)}
icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-certificates.svg`}
color="success"
/>
<CourseWidgetSummary
title="ДЗ к выполнению"
total={Number(stats?.homework_pending || 0)}
icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-completed.svg`}
color="warning"
/>
<CourseWidgetSummary
title="Средняя оценка"
total={
stats?.average_grade
? `${parseFloat(Number(stats.average_grade).toFixed(1))}/5`
: '—'
}
icon={`${CONFIG.site.basePath}/assets/icons/glass/ic-glass-bag.svg`}
color="info"
/>
</Box>
{/* Progress bar */}
<Card sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h6">Прогресс занятий</Typography>
<Typography variant="h6" sx={{ color: 'primary.main' }}>
{completionPct}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={completionPct}
sx={{ height: 8, borderRadius: 1, bgcolor: 'background.neutral' }}
/>
<Stack direction="row" justifyContent="space-between" sx={{ mt: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Пройдено: {stats?.completed_lessons || 0}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Всего: {stats?.total_lessons || 0}
</Typography>
</Stack>
</Card>
{/* Upcoming lessons + homework */}
<Box
sx={{
gap: 3,
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)' },
}}
>
{/* Upcoming lessons */}
<Card>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 3, py: 2 }}
>
<Typography variant="h6">Ближайшие занятия</Typography>
<Iconify icon="solar:calendar-bold" width={20} sx={{ color: 'primary.main' }} />
</Stack>
<Divider />
{loading ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
</Box>
) : stats?.upcoming_lessons?.length > 0 ? (
<Box sx={{ py: 1 }}>
{stats.upcoming_lessons.slice(0, 4).map((lesson) => (
<LessonItem key={lesson.id} lesson={lesson} />
))}
</Box>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
<Iconify icon="solar:calendar-bold" width={40} sx={{ mb: 1, opacity: 0.3 }} />
<Typography variant="body2">Нет запланированных занятий</Typography>
</Box>
)}
</Card>
{/* Homework */}
<Card>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 3, py: 2 }}
>
<Typography variant="h6">Домашние задания</Typography>
<Iconify icon="solar:document-bold" width={20} sx={{ color: 'warning.main' }} />
</Stack>
<Divider />
{loading ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
</Box>
) : stats?.recent_homework?.length > 0 ? (
<Box sx={{ py: 1 }}>
{stats.recent_homework.slice(0, 4).map((hw) => (
<HomeworkItem key={hw.id} homework={hw} />
))}
</Box>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
<Iconify icon="solar:document-bold" width={40} sx={{ mb: 1, opacity: 0.3 }} />
<Typography variant="body2">Нет домашних заданий</Typography>
</Box>
)}
</Card>
</Box>
</Box>
{/* RIGHT sidebar */}
<Box
sx={{
width: 1,
display: 'flex',
flexDirection: 'column',
px: { xs: 2, sm: 3, xl: 5 },
pt: { lg: 8 },
pb: { xs: 8, xl: 10 },
flexShrink: 0,
gap: 3,
maxWidth: { lg: 300, xl: 340 },
bgcolor: { lg: 'background.neutral' },
}}
>
<CourseMyAccount user={childId ? { first_name: childName } : user} />
{/* Next lesson highlight */}
{stats?.next_lesson && (
<Card sx={{ p: 2.5, bgcolor: 'primary.lighter' }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.dark' }}>
Следующее занятие
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{stats.next_lesson.title || 'Занятие'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatDateTime(stats.next_lesson.start_time)}
</Typography>
{stats.next_lesson.mentor && (
<Stack direction="row" alignItems="center" spacing={1} sx={{ mt: 1.5 }}>
<Avatar sx={{ width: 24, height: 24, fontSize: 10, bgcolor: 'primary.main' }}>
{(stats.next_lesson.mentor.first_name || 'М')[0]}
</Avatar>
<Typography variant="caption">
{`${stats.next_lesson.mentor.first_name || ''} ${stats.next_lesson.mentor.last_name || ''}`.trim()}
</Typography>
</Stack>
)}
</Card>
)}
</Box>
</Box>
</DashboardContent>
);
}

View File

@ -1 +1,2 @@
export * from './payment-view'; export * from './payment-view';
export { PaymentPlatformView } from './payment-platform-view';

View File

@ -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 (
<DashboardContent>
<CustomBreadcrumbs
heading="Подписки и оплата"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Оплата' }]}
sx={{ mb: 3 }}
/>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={3}>
{/* Subscriptions */}
{subscriptions.length > 0 ? (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Активные подписки
</Typography>
<Stack spacing={1.5}>
{subscriptions.map((sub, idx) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={idx}>
{idx > 0 && <Divider sx={{ mb: 1.5 }} />}
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box>
<Typography variant="subtitle2">
{sub.plan_name || sub.name || 'Подписка'}
</Typography>
{sub.expires_at && (
<Typography variant="caption" color="text.secondary">
До {formatDate(sub.expires_at)}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} alignItems="center">
{sub.status && (
<Chip
label={sub.status}
size="small"
color={sub.status === 'active' ? 'success' : 'default'}
/>
)}
{sub.price != null && (
<Typography variant="subtitle2">
{formatAmount(sub.price, sub.currency)}
</Typography>
)}
</Stack>
</Stack>
</Box>
))}
</Stack>
</CardContent>
</Card>
) : (
<Card variant="outlined">
<CardContent>
<Stack alignItems="center" spacing={2} sx={{ py: 3 }}>
<Iconify icon="eva:credit-card-outline" width={48} color="text.disabled" />
<Typography variant="body1" color="text.secondary">
Нет активных подписок
</Typography>
<Button variant="contained" startIcon={<Iconify icon="eva:plus-fill" />}>
Подключить подписку
</Button>
</Stack>
</CardContent>
</Card>
)}
{/* Payment history */}
{history.length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
История платежей
</Typography>
<Stack spacing={1}>
{history.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 0.75 }}>
<Box>
<Typography variant="body2">
{item.description || item.plan_name || 'Платёж'}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(item.created_at || item.date)}
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="subtitle2">
{formatAmount(item.amount, item.currency)}
</Typography>
{item.status && (
<Chip
label={item.status}
size="small"
color={item.status === 'success' || item.status === 'paid' ? 'success' : 'default'}
/>
)}
</Stack>
</Stack>
))}
</Stack>
</CardContent>
</Card>
)}
</Stack>
)}
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export { ReferralsView } from './referrals-view';

View File

@ -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 (
<Card variant="outlined" sx={{ flex: 1, minWidth: 140 }}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${color}.lighter`,
}}
>
<Iconify icon={icon} width={22} color={`${color}.main`} />
</Box>
<Typography variant="h5" fontWeight="bold">
{value ?? '—'}
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
</CardContent>
</Card>
);
}
function ReferralTable({ title, items }) {
if (!items || items.length === 0) return null;
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
{title}
</Typography>
<Stack spacing={1}>
{items.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1, bgcolor: 'background.neutral', borderRadius: 1 }}>
<Typography variant="body2">{item.email}</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Chip label={item.level} size="small" variant="outlined" />
<Typography variant="caption" color="text.secondary">
{item.total_points} pts
</Typography>
</Stack>
</Stack>
))}
</Stack>
</Box>
);
}
// ----------------------------------------------------------------------
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 (
<DashboardContent>
<CustomBreadcrumbs
heading="Реферальная программа"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Рефералы' }]}
sx={{ mb: 3 }}
/>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={3}>
{/* Stats */}
{stats && (
<Stack direction="row" flexWrap="wrap" gap={2}>
<StatCard
label="Прямые рефералы"
value={stats.referrals?.direct}
icon="eva:people-outline"
color="primary"
/>
<StatCard
label="Непрямые рефералы"
value={stats.referrals?.indirect}
icon="eva:person-add-outline"
color="info"
/>
<StatCard
label="Всего баллов"
value={stats.total_points}
icon="eva:star-outline"
color="warning"
/>
<StatCard
label="Заработано"
value={stats.earnings?.total !== undefined ? `${stats.earnings.total}` : '—'}
icon="eva:credit-card-outline"
color="success"
/>
<StatCard
label="Бонусный баланс"
value={stats.bonus_account?.balance !== undefined ? `${stats.bonus_account.balance}` : '—'}
icon="eva:gift-outline"
color="error"
/>
</Stack>
)}
{/* Level */}
{stats?.current_level && (
<Card variant="outlined">
<CardContent>
<Stack direction="row" alignItems="center" spacing={2}>
<Iconify icon="eva:award-outline" width={32} color="warning.main" />
<Box>
<Typography variant="subtitle1">
Уровень {stats.current_level.level} {stats.current_level.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Ваш текущий реферальный уровень
</Typography>
</Box>
</Stack>
</CardContent>
</Card>
)}
{/* Referral code & link */}
{referralCode && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Ваш реферальный код
</Typography>
<Stack spacing={2}>
<TextField
label="Реферальный код"
value={referralCode}
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
<IconButton onClick={handleCopyCode} size="small">
<Iconify icon={copied ? 'eva:checkmark-outline' : 'eva:copy-outline'} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
fullWidth
size="small"
/>
{referralLink && (
<TextField
label="Реферальная ссылка"
value={referralLink}
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
<IconButton onClick={handleCopyLink} size="small">
<Iconify icon={copied ? 'eva:checkmark-outline' : 'eva:copy-outline'} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
fullWidth
size="small"
/>
)}
<Button
variant="outlined"
startIcon={<Iconify icon="eva:share-outline" />}
onClick={handleCopyLink || handleCopyCode}
sx={{ alignSelf: 'flex-start' }}
>
Поделиться
</Button>
</Stack>
</CardContent>
</Card>
)}
{/* Referrals list */}
{referrals && (referrals.direct?.length > 0 || referrals.indirect?.length > 0) && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Мои рефералы
</Typography>
<Stack spacing={3}>
<ReferralTable title="Прямые" items={referrals.direct} />
{referrals.direct?.length > 0 && referrals.indirect?.length > 0 && <Divider />}
<ReferralTable title="Непрямые" items={referrals.indirect} />
</Stack>
</CardContent>
</Card>
)}
{!referralCode && !loading && (
<Typography variant="body1" color="text.secondary">
Реферальная программа недоступна
</Typography>
)}
</Stack>
)}
</DashboardContent>
);
}

View File

@ -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 = (
<img src={objectUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8 }} />
);
} else if (kind === 'video' && objectUrl) {
previewBlock = (
<video src={objectUrl} muted playsInline preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8 }} />
);
} else if (kind === 'pdf' && objectUrl) {
previewBlock = (
<iframe src={objectUrl} title={file.name} style={{ width: '100%', height: '100%', border: 'none', borderRadius: 8, minHeight: 140 }} />
);
} else {
previewBlock = (
<div style={{ width: '100%', height: '100%', minHeight: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.08)', borderRadius: 8 }}>
<span style={{ fontSize: 28 }}>📄</span>
</div>
);
}
return (
<div style={{ background: 'rgba(0,0,0,0.06)', borderRadius: 12, border: '1px solid rgba(0,0,0,0.12)', overflow: 'hidden' }}>
<div style={{ aspectRatio: '4/3', minHeight: 80, maxHeight: 140 }}>{previewBlock}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</div>
<div style={{ fontSize: 11, opacity: 0.6 }}>{formatSize(file.size)}</div>
</div>
<button
type="button"
onClick={onRemove}
disabled={disabled}
aria-label="Удалить"
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: 'none', background: 'rgba(186,26,26,0.15)', color: '#ba1a1a', cursor: disabled ? 'not-allowed' : 'pointer' }}
>
</button>
</div>
</div>
);
}
// ----------------------------------------------------------------------
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 (
<div style={panelStyle} onTransitionEnd={handleTransitionEnd}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid #eee', flexShrink: 0 }}>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}>{stepTitle}</h2>
<button type="button" onClick={handleClose} disabled={loading} style={{ width: 44, height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', borderRadius: 12, cursor: 'pointer', fontSize: 20 }}>
</button>
</div>
<div style={{ padding: '24px 28px', overflowY: 'auto', flex: 1 }}>
{step === 'choose_exit' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button type="button" onClick={handleJustExit} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
🚪 Выйти
</button>
{lessonId != null && (
<button type="button" onClick={() => setStep('grades')} disabled={loading} style={{ ...btnPrimary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
Выйти и завершить занятие
</button>
)}
</div>
)}
{step === 'grades' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ margin: '0 0 8px', fontSize: 14, color: '#666' }}>Необязательно</p>
<div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="exit-mentor-grade" style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: '#555' }}>
Оценка за занятие (15)
</label>
<input
id="exit-mentor-grade"
type="number"
min={1}
max={5}
value={formData.mentor_grade}
onChange={(e) => setFormData((d) => ({ ...d, mentor_grade: e.target.value }))}
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15 }}
/>
</div>
<div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="exit-school-grade" style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: '#555' }}>
Оценка в школе (15)
</label>
<input
id="exit-school-grade"
type="number"
min={1}
max={5}
value={formData.school_grade}
onChange={(e) => setFormData((d) => ({ ...d, school_grade: e.target.value }))}
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15 }}
/>
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
<button type="button" onClick={() => setStep('choose_exit')} disabled={loading} style={btnSecondary}>Назад</button>
<button type="button" onClick={() => setStep('comment')} disabled={loading} style={{ ...btnPrimary, flex: 1 }}>Далее</button>
</div>
</div>
)}
{step === 'comment' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ margin: '0 0 8px', fontSize: 14, color: '#666' }}>Комментарий к занятию (необязательно)</p>
<textarea
value={formData.mentor_notes}
onChange={(e) => setFormData((d) => ({ ...d, mentor_notes: e.target.value }))}
rows={5}
placeholder="Что прошли, успехи, рекомендации..."
disabled={loading}
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15, resize: 'vertical' }}
/>
<div style={{ display: 'flex', gap: 12 }}>
<button type="button" onClick={() => setStep('grades')} disabled={loading} style={btnSecondary}>Назад</button>
<button type="button" onClick={() => setStep('choose_hw')} disabled={loading} style={{ ...btnPrimary, flex: 1 }}>Далее</button>
</div>
</div>
)}
{step === 'choose_hw' && (
<>
<p style={{ margin: '0 0 16px', fontSize: 15, color: '#666' }}>Выдать домашнее задание?</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<button type="button" onClick={() => setStep('comment')} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
Назад
</button>
<button type="button" onClick={() => setStep('hw_form')} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
📋 Да
</button>
<button type="button" onClick={handleCompleteNoHw} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
Нет
</button>
<button type="button" onClick={handleCompleteHwLater} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
🕐 Позже (заполню на странице ДЗ)
</button>
</div>
</>
)}
{step === 'hw_form' && (
<>
<div style={{ marginBottom: 20 }}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="exit-hw-text" style={{ display: 'block', fontSize: 14, fontWeight: 600, marginBottom: 8, color: '#555' }}>
Текст задания
</label>
<textarea
id="exit-hw-text"
value={homeworkText}
onChange={(e) => setHomeworkText(e.target.value)}
rows={4}
placeholder="Опишите домашнее задание..."
disabled={loading}
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15, resize: 'vertical' }}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: '#555' }}>
Файлы (до {MAX_FILE_SIZE_MB} МБ, не более {MAX_LESSON_FILES} шт.)
</div>
<input
id="exit-lesson-file-input"
type="file"
multiple
onChange={handleFileChange}
disabled={loading}
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
htmlFor="exit-lesson-file-input"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, padding: '16px 20px', borderRadius: 12, border: '2px dashed #ddd', background: '#f9f9f9', fontSize: 15, cursor: loading ? 'not-allowed' : 'pointer' }}
>
📎 Прикрепить файлы
</label>
{files.length > 0 && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{files.map((f, i) => (
<FilePreviewChip
key={`${f.name}-${i}-${f.size}`}
file={f}
onRemove={() => setFiles((p) => p.filter((_, j) => j !== i))}
disabled={loading}
/>
))}
</div>
)}
</div>
{error && (
<div style={{ background: 'rgba(186,26,26,0.1)', border: '1px solid #ba1a1a', borderRadius: 12, padding: 12, marginBottom: 16, fontSize: 14, color: '#ba1a1a' }}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<button type="button" onClick={() => setStep('choose_hw')} disabled={loading} style={btnSecondary}>Назад</button>
<button type="button" onClick={handleCompleteHwLater} disabled={loading} style={{ ...btnSecondary, flex: 1, minWidth: 100 }}>Позже</button>
<button type="button" onClick={handleSaveHw} disabled={loading} style={{ ...btnPrimary, flex: 1, minWidth: 100, opacity: loading ? 0.8 : 1 }}>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { VideoCallView } from './video-call-view';

View File

@ -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 <div style={{ flex: 1, background: '#000', minHeight: 200 }} />;
// 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 (
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.75)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: 24 }}>
<p style={{ color: '#fff', fontSize: 18, textAlign: 'center', margin: 0 }}>Чтобы слышать собеседника, разрешите воспроизведение звука</p>
<button type="button" onClick={handleClick} style={{ padding: '16px 32px', fontSize: 18, fontWeight: 600, borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer' }}>
🔊 Разрешить звук
</button>
</div>
);
}
// ----------------------------------------------------------------------
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 (
<div style={{ position: 'fixed', bottom: 24, right: chatOpen ? CHAT_PANEL_WIDTH + 24 : 24, width: 280, height: 158, zIndex: 10000, borderRadius: 12, overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', border: '2px solid rgba(255,255,255,0.2)', background: '#000' }}>
<ParticipantTile trackRef={remoteRef} />
</div>
);
}
// ----------------------------------------------------------------------
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 (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', background: '#111' }}>
Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
</div>
);
}
return (
<div
ref={iframeRef}
style={{ width: '100%', height: '100%', pointerEvents: showingBoard ? 'auto' : 'none' }}
/>
);
}
// ----------------------------------------------------------------------
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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%)' }}>
<div style={{ background: '#fff', borderRadius: 20, maxWidth: 520, width: '100%', overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.1)' }}>
<div style={{ background: 'linear-gradient(135deg, #1976d2 0%, #7c4dff 100%)', padding: 24, color: '#fff' }}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>Настройки перед входом</h1>
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>Настройте камеру и микрофон</p>
</div>
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, background: '#000', borderRadius: 12, aspectRatio: '16/9', overflow: 'hidden' }}>
{videoEnabled ? (
<video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', color: '#666', minHeight: 120 }}>
<span style={{ fontSize: 48 }}>📵</span>
<p style={{ margin: 8 }}>Камера выключена</p>
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
{[
{ label: 'Микрофон', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) },
{ label: 'Камера', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) },
].map(({ label, enabled, toggle }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 16, background: '#f5f5f5', borderRadius: 12 }}>
<span>{label}</span>
<button onClick={toggle} type="button" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: enabled ? '#1976d2' : '#888', color: '#fff', cursor: 'pointer' }}>
{enabled ? 'Выключить' : 'Включить'}
</button>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button type="button" onClick={onCancel} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: '1px solid #ddd', background: 'transparent', cursor: 'pointer' }}>Отмена</button>
<button type="button" onClick={handleJoin} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: 'none', background: '#1976d2', color: '#fff', fontWeight: 600, cursor: 'pointer' }}>
📹 Войти в конференцию
</button>
</div>
</div>
</div>
</div>
);
}
// ----------------------------------------------------------------------
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) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
style={{
width: 48,
height: 48,
borderRadius: 12,
border: 'none',
background: active ? '#1976d2' : 'rgba(255,255,255,0.2)',
color: '#fff',
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
opacity: disabled ? 0.6 : 1,
}}
>
{icon}
</button>
);
return (
<div style={{ height: '100vh', position: 'relative', display: 'flex' }}>
<StartAudioOverlay />
<div style={{ flex: 1, position: 'relative', background: '#000' }}>
<div style={{ position: 'absolute', inset: 0, zIndex: showBoard ? 0 : 1 }}>
<LiveKitLayoutErrorBoundary>
<VideoConference chatMessageFormatter={(message) => message} />
</LiveKitLayoutErrorBoundary>
</div>
</div>
{typeof document !== 'undefined' &&
createPortal(
<>
{showBoard && <RemoteParticipantPiP chatOpen={showPlatformChat} />}
{showBoard && (
<button
type="button"
onClick={() => setShowNavMenu((v) => !v)}
style={{ position: 'fixed', left: 16, bottom: 64, width: 48, height: 48, borderRadius: 12, border: 'none', background: 'rgba(0,0,0,0.7)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002, fontSize: 20 }}
title="Меню"
>
</button>
)}
{showNavMenu && (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
onClick={() => 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 */}
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 16, padding: 24, minWidth: 240 }}>
<button type="button" onClick={() => { setShowNavMenu(false); router.push('/dashboard'); }} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
🏠 На главную
</button>
<button type="button" onClick={() => setShowNavMenu(false)} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
Закрыть
</button>
</div>
</div>
)}
<div style={sidebarStyle}>
{iconBtn(!showBoard, false, 'Камера', '📹', () => setShowBoard(false))}
{iconBtn(showBoard, !boardId || boardLoading, boardLoading ? 'Загрузка доски...' : !boardId ? 'Доска недоступна' : 'Доска', boardLoading ? '⏳' : '🎨', () => boardId && !boardLoading && setShowBoard(true))}
{lessonId != null && iconBtn(showPlatformChat, false, 'Чат', '💬', () => setShowPlatformChat((v) => !v))}
</div>
</>,
document.body
)}
<ExitLessonModal
isOpen={showExitModal}
lessonId={lessonId}
onClose={() => setShowExitModal(false)}
onExit={() => room.disconnect()}
/>
</div>
);
}
// ----------------------------------------------------------------------
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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f5f5f5' }}>
<div style={{ textAlign: 'center', padding: 24 }}>
<p style={{ fontSize: 18, marginBottom: 16 }}>Урок завершён. Видеоконференция недоступна.</p>
<button type="button" onClick={() => router.push('/dashboard')} style={{ padding: '12px 24px', borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer' }}>
На главную
</button>
</div>
</div>
);
}
if (!accessToken || !serverUrl) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<p>Загрузка...</p>
</div>
);
}
if (showPreJoin) {
return (
<PreJoinScreen
onJoin={(audio, video) => {
try {
sessionStorage.setItem(SS_PREJOIN_DONE, '1');
} catch { /* ignore */ }
setAudioEnabled(audio);
setVideoEnabled(video);
setShowPreJoin(false);
}}
onCancel={() => router.push('/dashboard')}
/>
);
}
if (!avReady) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<p>Загрузка...</p>
</div>
);
}
return (
<>
{boardId && (
<div
key="board-layer"
style={{ position: 'fixed', inset: 0, zIndex: showBoard ? 9999 : 0, pointerEvents: showBoard ? 'auto' : 'none' }}
>
<WhiteboardIframe boardId={boardId} showingBoard={showBoard} />
</div>
)}
<LiveKitRoom
token={accessToken}
serverUrl={serverUrl}
connect
audio={audioEnabled}
video={videoEnabled}
onDisconnected={() => 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 },
}}
>
<RoomContent
lessonId={effectiveLessonId}
boardId={boardId}
boardLoading={boardLoading}
showBoard={showBoard}
setShowBoard={setShowBoard}
/>
<RoomAudioRenderer />
<ConnectionStateToast />
</LiveKitRoom>
</>
);
}

File diff suppressed because one or more lines are too long

View File

@ -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;
}

View File

@ -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;
}

View File

@ -8,7 +8,7 @@ const axiosInstance = axios.create({ baseURL: CONFIG.site.serverUrl });
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error) => Promise.reject((error.response && error.response.data) || 'Something went wrong!') (error) => Promise.reject(error)
); );
export default axiosInstance; export default axiosInstance;
@ -31,13 +31,17 @@ export const fetcher = async (args) => {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const endpoints = { export const endpoints = {
chat: '/api/chat', chat: '/chat',
kanban: '/api/kanban', kanban: '/kanban',
calendar: '/api/calendar', calendar: '/calendar',
auth: { auth: {
me: '/profile/me/', me: '/profile/me/',
signIn: '/auth/login/', signIn: '/auth/login/',
signUp: '/auth/register/', signUp: '/auth/register/',
refresh: '/auth/token/refresh/',
passwordReset: '/auth/password-reset/',
passwordResetConfirm: '/auth/password-reset-confirm/',
verifyEmail: '/auth/verify-email/',
}, },
mail: { mail: {
list: '/api/mail/list', list: '/api/mail/list',

View File

@ -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;
}

View File

@ -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 ?? [];
}

View File

@ -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: [],
};
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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() });
}

View File

@ -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;
}

View File

@ -1689,6 +1689,11 @@
"@babel/helper-validator-identifier" "^7.22.20" "@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0" 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": "@dnd-kit/accessibility@^3.1.0":
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz" 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" resolved "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.0.tgz"
integrity sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA== integrity sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==
"@floating-ui/core@^1.6.0": "@floating-ui/core@^1.7.3":
version "1.6.0" version "1.7.5"
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz" resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz"
integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==
dependencies: dependencies:
"@floating-ui/utils" "^0.2.1" "@floating-ui/utils" "^0.2.11"
"@floating-ui/dom@^1.6.1": "@floating-ui/dom@^1.6.1", "@floating-ui/dom@1.7.4":
version "1.6.1" version "1.7.4"
resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz" resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz"
integrity sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ== integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==
dependencies: dependencies:
"@floating-ui/core" "^1.6.0" "@floating-ui/core" "^1.7.3"
"@floating-ui/utils" "^0.2.1" "@floating-ui/utils" "^0.2.10"
"@floating-ui/react-dom@^2.0.8": "@floating-ui/react-dom@^2.0.8":
version "2.0.8" version "2.0.8"
@ -2281,10 +2286,10 @@
dependencies: dependencies:
"@floating-ui/dom" "^1.6.1" "@floating-ui/dom" "^1.6.1"
"@floating-ui/utils@^0.2.1": "@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.11":
version "0.2.1" version "0.2.11"
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz" resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz"
integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
"@fontsource/barlow@^5.0.13": "@fontsource/barlow@^5.0.13":
version "5.0.13" version "5.0.13"
@ -2461,6 +2466,43 @@
"@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@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": "@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2":
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz" 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" resolved "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz"
integrity sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg== 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" version "14.2.4"
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.4.tgz" resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.4.tgz"
integrity sha512-tkLrjBzqFTP8DVrAAQmZelEahfR9OxWpFR++vAI9FBhCiIxtwHwBHC23SBHCTURBtwB4kc/x44imVOnkKGNVGg== 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": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
@ -3716,6 +3763,11 @@
dependencies: dependencies:
"@types/ms" "*" "@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@*": "@types/geojson@*":
version "7946.0.13" version "7946.0.13"
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz" 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" resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== 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" version "2.1.1"
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
@ -4574,6 +4626,11 @@ data-view-byte-offset@^1.0.0:
es-errors "^1.3.0" es-errors "^1.3.0"
is-data-view "^1.0.1" 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: dayjs@^1.10.7, dayjs@^1.11.11:
version "1.11.11" version "1.11.11"
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz"
@ -6088,6 +6145,11 @@ jay-peg@^1.0.2:
dependencies: dependencies:
restructure "^3.0.0" 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: js-cookie@^3.0.5:
version "3.0.5" version "3.0.5"
resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz" 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" resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz"
integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg== 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: locate-path@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz" 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" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 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: long@^5.0.0:
version "5.2.3" version "5.2.3"
resolved "https://registry.npmjs.org/long/-/long-5.2.3.tgz" resolved "https://registry.npmjs.org/long/-/long-5.2.3.tgz"
@ -7413,7 +7501,7 @@ react-apexcharts@^1.4.1:
dependencies: dependencies:
prop-types "^15.8.1" 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" version "18.3.1"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
@ -7551,7 +7639,7 @@ react-transition-group@^4.4.5:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" 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" version "18.3.1"
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== 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" resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
rxjs@^7.8.1: rxjs@*, rxjs@^7.8.1, rxjs@7.8.2:
version "7.8.1" version "7.8.2"
resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz"
integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
dependencies: dependencies:
tslib "^2.1.0" tslib "^2.1.0"
@ -7825,6 +7913,16 @@ scrollparent@^2.1.0:
resolved "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz" resolved "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz"
integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA== 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: semver@^6.3.0, semver@^6.3.1:
version "6.3.1" version "6.3.1"
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" 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" resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== 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: tsconfig-paths@^3.15.0:
version "3.15.0" version "3.15.0"
resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz" 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" resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== 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: 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.6.2" version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
turndown@^7.2.0: turndown@^7.2.0:
version "7.2.0" version "7.2.0"
@ -8388,6 +8491,13 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13" is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0" 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: typescript@^5.4.5, typescript@>=4.2.0, typescript@>=4.9.5:
version "5.4.5" version "5.4.5"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz" 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" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== 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: util-deprecate@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" 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" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 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: websocket-driver@>=0.5.1:
version "0.7.4" version "0.7.4"
resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz" resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz"