From bc7c2052ae0857a4c504e5a297103dadbcc7fc5b Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 1 Apr 2026 10:22:18 +0000 Subject: [PATCH] hhhh --- backend/src/index.js | 3 +- backend/src/routes/learningHub.js | 548 ++++++++++++++++++++++ frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 7 + frontend/src/pages/academy/[courseId].tsx | 376 +++++++++++++++ frontend/src/pages/academy/index.tsx | 359 ++++++++++++++ frontend/src/pages/index.tsx | 344 ++++++++------ 8 files changed, 1492 insertions(+), 151 deletions(-) create mode 100644 backend/src/routes/learningHub.js create mode 100644 frontend/src/pages/academy/[courseId].tsx create mode 100644 frontend/src/pages/academy/index.tsx diff --git a/backend/src/index.js b/backend/src/index.js index 969e117..7bb74d2 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,6 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -46,6 +45,7 @@ const course_reviewsRoutes = require('./routes/course_reviews'); const certificatesRoutes = require('./routes/certificates'); const support_ticketsRoutes = require('./routes/support_tickets'); +const learningHubRoutes = require('./routes/learningHub'); const getBaseUrl = (url) => { @@ -130,6 +130,7 @@ app.use('/api/course_reviews', passport.authenticate('jwt', {session: false}), c app.use('/api/certificates', passport.authenticate('jwt', {session: false}), certificatesRoutes); app.use('/api/support_tickets', passport.authenticate('jwt', {session: false}), support_ticketsRoutes); +app.use('/api/learning-hub', passport.authenticate('jwt', {session: false}), learningHubRoutes); app.use( '/api/openai', diff --git a/backend/src/routes/learningHub.js b/backend/src/routes/learningHub.js new file mode 100644 index 0000000..5b73823 --- /dev/null +++ b/backend/src/routes/learningHub.js @@ -0,0 +1,548 @@ +const express = require('express'); +const validator = require('validator'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +function buildBadRequest(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function ensureUuid(value, label) { + if (!value || !validator.isUUID(String(value))) { + throw buildBadRequest(`${label} is invalid.`); + } +} + +function formatCourse(course, extras = {}) { + if (!course) { + return null; + } + + return { + id: course.id, + title: course.title, + slug: course.slug, + short_description: course.short_description, + description: course.description, + level: course.level, + language: course.language, + price: course.price, + is_free: course.is_free, + status: course.status, + published_at: course.published_at, + topic: course.topic + ? { + id: course.topic.id, + title: course.topic.title, + summary: course.topic.summary, + } + : null, + instructor: course.instructor + ? { + id: course.instructor.id, + firstName: course.instructor.firstName, + lastName: course.instructor.lastName, + email: course.instructor.email, + } + : null, + thumbnail: Array.isArray(course.thumbnail) ? course.thumbnail : [], + ...extras, + }; +} + +function buildProgress(courseId, courseTotals, completedTotals) { + const totalLessons = courseTotals.get(courseId) || 0; + const completedLessons = completedTotals.get(courseId) || 0; + const progressPercent = totalLessons + ? Math.round((completedLessons / totalLessons) * 100) + : 0; + + return { + totalLessons, + completedLessons, + progressPercent, + }; +} + +async function collectLessonTotalsByCourse(courseIds, userId) { + if (!courseIds.length) { + return { + courseTotals: new Map(), + completedTotals: new Map(), + progressByLessonId: new Map(), + }; + } + + const sections = await db.course_sections.findAll({ + where: { courseId: courseIds }, + attributes: ['id', 'courseId'], + order: [['position', 'ASC'], ['createdAt', 'ASC']], + }); + + const sectionToCourse = new Map( + sections.map((section) => [section.id, section.courseId]), + ); + + const lessons = await db.lessons.findAll({ + where: { sectionId: sections.map((section) => section.id) }, + attributes: ['id', 'sectionId'], + order: [['position', 'ASC'], ['createdAt', 'ASC']], + }); + + const courseTotals = new Map(); + + lessons.forEach((lesson) => { + const courseId = sectionToCourse.get(lesson.sectionId); + if (!courseId) { + return; + } + + courseTotals.set(courseId, (courseTotals.get(courseId) || 0) + 1); + }); + + const progressRows = lessons.length + ? await db.lesson_progress.findAll({ + where: { + studentId: userId, + lessonId: lessons.map((lesson) => lesson.id), + }, + attributes: [ + 'id', + 'lessonId', + 'state', + 'progress_percent', + 'started_at', + 'completed_at', + 'last_seen_at', + ], + order: [['updatedAt', 'DESC']], + }) + : []; + + const progressByLessonId = new Map(); + const completedTotals = new Map(); + + progressRows.forEach((row) => { + const plain = row.get({ plain: true }); + progressByLessonId.set(plain.lessonId, plain); + + if (plain.state === 'completed') { + const lesson = lessons.find((item) => item.id === plain.lessonId); + const courseId = lesson ? sectionToCourse.get(lesson.sectionId) : null; + if (courseId) { + completedTotals.set(courseId, (completedTotals.get(courseId) || 0) + 1); + } + } + }); + + return { + courseTotals, + completedTotals, + progressByLessonId, + }; +} + +router.get( + '/overview', + wrapAsync(async (req, res) => { + const { currentUser } = req; + const { topicId, q } = req.query; + + if (topicId) { + ensureUuid(topicId, 'Topic'); + } + + const courseWhere = {}; + + if (topicId) { + courseWhere.topicId = topicId; + } + + if (q) { + courseWhere.title = { + [db.Sequelize.Op.iLike]: `%${String(q).trim()}%`, + }; + } + + const [topics, courses, enrollments] = await Promise.all([ + db.topics.findAll({ + include: [ + { + model: db.categories, + as: 'category', + required: false, + attributes: ['id', 'title'], + }, + ], + order: [['is_published', 'DESC'], ['title', 'ASC']], + limit: 12, + }), + db.courses.findAll({ + where: courseWhere, + include: [ + { + model: db.topics, + as: 'topic', + required: false, + attributes: ['id', 'title', 'summary'], + }, + { + model: db.users, + as: 'instructor', + required: false, + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + { + model: db.file, + as: 'thumbnail', + required: false, + }, + ], + order: [ + ['status', 'ASC'], + ['published_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 18, + }), + db.enrollments.findAll({ + where: { studentId: currentUser.id }, + include: [ + { + model: db.courses, + as: 'course', + required: false, + include: [ + { + model: db.topics, + as: 'topic', + required: false, + attributes: ['id', 'title', 'summary'], + }, + { + model: db.file, + as: 'thumbnail', + required: false, + }, + ], + }, + ], + order: [['enrolled_at', 'DESC'], ['createdAt', 'DESC']], + }), + ]); + + const enrolledCourseIds = enrollments + .map((enrollment) => enrollment.courseId) + .filter(Boolean); + + const { courseTotals, completedTotals } = await collectLessonTotalsByCourse( + enrolledCourseIds, + currentUser.id, + ); + + const myLearning = enrollments.map((enrollment) => { + const plainEnrollment = enrollment.get({ plain: true }); + const progress = buildProgress( + plainEnrollment.courseId, + courseTotals, + completedTotals, + ); + + return { + id: plainEnrollment.id, + status: plainEnrollment.status, + payment_status: plainEnrollment.payment_status, + enrolled_at: plainEnrollment.enrolled_at, + completed_at: plainEnrollment.completed_at, + course: formatCourse(plainEnrollment.course, progress), + ...progress, + }; + }); + + const enrolledCourseSet = new Set(enrolledCourseIds); + + const catalog = courses.map((course) => { + const plainCourse = course.get({ plain: true }); + return formatCourse(plainCourse, { + isEnrolled: enrolledCourseSet.has(plainCourse.id), + }); + }); + + res.status(200).send({ + stats: { + activeCourses: myLearning.filter((item) => item.status === 'active').length, + completedCourses: myLearning.filter((item) => item.status === 'completed').length, + totalLessonsCompleted: Array.from(completedTotals.values()).reduce( + (sum, value) => sum + value, + 0, + ), + }, + topics: topics.map((topic) => ({ + id: topic.id, + title: topic.title, + summary: topic.summary, + is_published: topic.is_published, + category: topic.category + ? { + id: topic.category.id, + title: topic.category.title, + } + : null, + })), + catalog, + myLearning, + }); + }), +); + +router.get( + '/courses/:id', + wrapAsync(async (req, res) => { + const { currentUser } = req; + const { id } = req.params; + + ensureUuid(id, 'Course'); + + const course = await db.courses.findByPk(id, { + include: [ + { + model: db.topics, + as: 'topic', + required: false, + attributes: ['id', 'title', 'summary'], + }, + { + model: db.users, + as: 'instructor', + required: false, + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + { + model: db.file, + as: 'thumbnail', + required: false, + }, + ], + }); + + if (!course) { + const error = new Error('Course not found.'); + error.code = 404; + throw error; + } + + const sections = await db.course_sections.findAll({ + where: { courseId: id }, + order: [['position', 'ASC'], ['createdAt', 'ASC']], + }); + + const lessons = sections.length + ? await db.lessons.findAll({ + where: { sectionId: sections.map((section) => section.id) }, + order: [['position', 'ASC'], ['createdAt', 'ASC']], + }) + : []; + + const enrollment = await db.enrollments.findOne({ + where: { + studentId: currentUser.id, + courseId: id, + }, + }); + + const { courseTotals, completedTotals, progressByLessonId } = + await collectLessonTotalsByCourse([id], currentUser.id); + + const plainCourse = course.get({ plain: true }); + const progress = buildProgress(id, courseTotals, completedTotals); + + const lessonGroups = new Map(); + lessons.forEach((lesson) => { + const plainLesson = lesson.get({ plain: true }); + const progressRow = progressByLessonId.get(plainLesson.id) || null; + + if (!lessonGroups.has(plainLesson.sectionId)) { + lessonGroups.set(plainLesson.sectionId, []); + } + + lessonGroups.get(plainLesson.sectionId).push({ + id: plainLesson.id, + title: plainLesson.title, + lesson_type: plainLesson.lesson_type, + duration_minutes: plainLesson.duration_minutes, + is_preview: plainLesson.is_preview, + is_published: plainLesson.is_published, + position: plainLesson.position, + progress: progressRow, + }); + }); + + res.status(200).send({ + course: formatCourse(plainCourse, progress), + enrollment: enrollment ? enrollment.get({ plain: true }) : null, + progress, + sections: sections.map((section) => { + const plainSection = section.get({ plain: true }); + return { + id: plainSection.id, + title: plainSection.title, + summary: plainSection.summary, + position: plainSection.position, + is_published: plainSection.is_published, + lessons: lessonGroups.get(plainSection.id) || [], + }; + }), + }); + }), +); + +router.post( + '/courses/:id/enroll', + wrapAsync(async (req, res) => { + const { currentUser } = req; + const { id } = req.params; + + ensureUuid(id, 'Course'); + + const course = await db.courses.findByPk(id); + if (!course) { + const error = new Error('Course not found.'); + error.code = 404; + throw error; + } + + const existingEnrollment = await db.enrollments.findOne({ + where: { + studentId: currentUser.id, + courseId: id, + }, + }); + + if (existingEnrollment) { + return res.status(200).send({ + success: true, + alreadyEnrolled: true, + enrollment: existingEnrollment, + message: 'You are already enrolled in this course.', + }); + } + + const enrollment = await db.enrollments.create({ + status: 'active', + enrolled_at: new Date(), + payment_status: course.is_free ? 'paid' : 'unpaid', + paid_amount: course.is_free ? 0 : course.price, + studentId: currentUser.id, + courseId: id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + res.status(200).send({ + success: true, + alreadyEnrolled: false, + enrollment, + message: 'Course added to your personal learning cabinet.', + }); + }), +); + +router.post( + '/lessons/:id/progress', + wrapAsync(async (req, res) => { + const { currentUser } = req; + const { id } = req.params; + const { completed } = req.body; + + ensureUuid(id, 'Lesson'); + + if (typeof completed !== 'boolean') { + throw buildBadRequest('Completed flag must be a boolean value.'); + } + + const lesson = await db.lessons.findByPk(id, { + include: [ + { + model: db.course_sections, + as: 'section', + required: false, + attributes: ['id', 'courseId'], + }, + ], + }); + + if (!lesson || !lesson.section || !lesson.section.courseId) { + const error = new Error('Lesson not found.'); + error.code = 404; + throw error; + } + + const enrollment = await db.enrollments.findOne({ + where: { + studentId: currentUser.id, + courseId: lesson.section.courseId, + }, + }); + + if (!enrollment) { + const error = new Error('Enroll in the course before tracking lesson progress.'); + error.code = 403; + throw error; + } + + let progress = await db.lesson_progress.findOne({ + where: { + studentId: currentUser.id, + lessonId: id, + }, + }); + + const payload = { + state: completed ? 'completed' : 'not_started', + progress_percent: completed ? 100 : 0, + started_at: completed + ? progress?.started_at || new Date() + : null, + completed_at: completed ? new Date() : null, + last_seen_at: new Date(), + updatedById: currentUser.id, + }; + + if (!progress) { + progress = await db.lesson_progress.create({ + ...payload, + studentId: currentUser.id, + lessonId: id, + createdById: currentUser.id, + }); + } else { + await progress.update(payload); + } + + const { courseTotals, completedTotals } = await collectLessonTotalsByCourse( + [lesson.section.courseId], + currentUser.id, + ); + + res.status(200).send({ + success: true, + progress: progress.get({ plain: true }), + courseProgress: buildProgress( + lesson.section.courseId, + courseTotals, + completedTotals, + ), + message: completed + ? 'Lesson marked as completed.' + : 'Lesson marked as not started.', + }); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 366aeda..0bc4649 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,13 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/academy', + label: 'Academy Hub', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiSchoolOutline' in icon ? icon['mdiSchoolOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + }, { href: '/users/users-list', diff --git a/frontend/src/pages/academy/[courseId].tsx b/frontend/src/pages/academy/[courseId].tsx new file mode 100644 index 0000000..a286b56 --- /dev/null +++ b/frontend/src/pages/academy/[courseId].tsx @@ -0,0 +1,376 @@ +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import { mdiArrowLeft, mdiCheckCircle, mdiClockOutline, mdiLoading, mdiPlayCircleOutline, mdiSchoolOutline } from '@mdi/js'; +import BaseButton from '../../components/BaseButton'; +import BaseIcon from '../../components/BaseIcon'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import NotificationBar from '../../components/NotificationBar'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +interface CourseDetail { + id: string; + title: string; + short_description?: string; + description?: string; + level?: string; + language?: string; + is_free?: boolean; + price?: string | number; + topic?: { id: string; title: string; summary?: string } | null; + instructor?: { firstName?: string; lastName?: string } | null; + thumbnail?: Array<{ publicUrl?: string }>; +} + +interface LessonItem { + id: string; + title: string; + lesson_type?: string; + duration_minutes?: number; + is_preview?: boolean; + progress?: { + state?: string; + progress_percent?: number; + } | null; +} + +interface SectionItem { + id: string; + title: string; + summary?: string; + lessons: LessonItem[]; +} + +interface DetailResponse { + course: CourseDetail & { + progressPercent: number; + totalLessons: number; + completedLessons: number; + }; + enrollment: { + id: string; + status: string; + } | null; + progress: { + progressPercent: number; + totalLessons: number; + completedLessons: number; + }; + sections: SectionItem[]; +} + +function humanizeLevel(level?: string) { + if (!level) return 'Любой уровень'; + return level.replace(/_/g, ' '); +} + +function formatPrice(course: CourseDetail) { + if (course.is_free) return 'Бесплатно'; + if (course.price === null || course.price === undefined || course.price === '') return 'По запросу'; + return `${course.price} ₽`; +} + +export default function AcademyCoursePage() { + const router = useRouter(); + const { courseId } = router.query; + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + useEffect(() => { + if (!courseId || typeof courseId !== 'string') { + return; + } + + const fetchDetail = async () => { + try { + setIsLoading(true); + const response = await axios.get(`/learning-hub/courses/${courseId}`); + setDetail(response.data); + setErrorMessage(''); + } catch (error) { + const message = axios.isAxiosError(error) + ? error.response?.data || 'Не удалось загрузить курс.' + : 'Не удалось загрузить курс.'; + setErrorMessage(String(message)); + } finally { + setIsLoading(false); + } + }; + + fetchDetail().then(); + }, [courseId]); + + const completedLessonIds = useMemo(() => { + const ids = new Set(); + detail?.sections.forEach((section) => { + section.lessons.forEach((lesson) => { + if (lesson.progress?.state === 'completed') { + ids.add(lesson.id); + } + }); + }); + return ids; + }, [detail]); + + const handleEnroll = async () => { + if (!courseId || typeof courseId !== 'string') { + return; + } + + try { + setIsSubmitting(true); + const response = await axios.post(`/learning-hub/courses/${courseId}/enroll`); + setSuccessMessage(response.data.message || 'Курс добавлен в ваш кабинет.'); + const refreshed = await axios.get(`/learning-hub/courses/${courseId}`); + setDetail(refreshed.data); + } catch (error) { + const message = axios.isAxiosError(error) + ? error.response?.data || 'Не удалось записаться на курс.' + : 'Не удалось записаться на курс.'; + setErrorMessage(String(message)); + } finally { + setIsSubmitting(false); + } + }; + + const toggleLesson = async (lessonId: string, completed: boolean) => { + if (!courseId || typeof courseId !== 'string' || !detail) { + return; + } + + try { + setIsSubmitting(true); + const response = await axios.post(`/learning-hub/lessons/${lessonId}/progress`, { + completed, + }); + + setDetail({ + ...detail, + progress: response.data.courseProgress, + course: { + ...detail.course, + ...response.data.courseProgress, + }, + sections: detail.sections.map((section) => ({ + ...section, + lessons: section.lessons.map((lesson) => ( + lesson.id === lessonId + ? { + ...lesson, + progress: response.data.progress, + } + : lesson + )), + })), + }); + setSuccessMessage(response.data.message || 'Прогресс обновлён.'); + } catch (error) { + const message = axios.isAxiosError(error) + ? error.response?.data || 'Не удалось обновить прогресс.' + : 'Не удалось обновить прогресс.'; + setErrorMessage(String(message)); + } finally { + setIsSubmitting(false); + } + }; + + const heroStyle = detail?.course.thumbnail?.[0]?.publicUrl + ? { + backgroundImage: `linear-gradient(135deg, rgba(15,23,42,0.35), rgba(15,23,42,0.68)), url(${detail.course.thumbnail[0].publicUrl})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + } + : undefined; + + return ( + <> + + {getPageTitle(detail?.course.title || 'Course')} + + + + + + + {errorMessage && ( + + {errorMessage} + + )} + {successMessage && ( + + {successMessage} + + )} + + {isLoading ? ( + +
+ + Загружаем программу курса... +
+
+ ) : detail ? ( + <> + +
+
+
+ {detail.course.topic?.title && {detail.course.topic.title}} + {humanizeLevel(detail.course.level)} + {formatPrice(detail.course)} +
+

{detail.course.title}

+

{detail.course.short_description || detail.course.description || 'Подробное описание курса появится здесь.'}

+
+ {detail.course.instructor?.firstName ? `${detail.course.instructor.firstName} ${detail.course.instructor.lastName || ''}`.trim() : 'MindSpring Coach'} + {detail.progress.completedLessons}/{detail.progress.totalLessons} уроков закрыто +
+
+ +
+
+

Текущий статус

+
+
+ Прогресс по курсу + {detail.progress.progressPercent}% +
+
+
+
+
{detail.progress.completedLessons} из {detail.progress.totalLessons} уроков отмечены как завершённые.
+
+
+ +
+ {detail.enrollment ? ( + <> +
+ Курс уже находится в вашем личном кабинете. +
+ + + ) : ( + + )} + + + Назад к подборке курсов + +
+
+
+ + +
+
+ {detail.sections.length === 0 ? ( + +
+

Программа пока не заполнена

+

Добавьте секции и уроки в админке, чтобы наполнить курс.

+
+
+ ) : ( + detail.sections.map((section, index) => ( + +
+
+

Секция {index + 1}

+

{section.title}

+ {section.summary &&

{section.summary}

} +
+
{section.lessons.length} уроков
+
+ +
+ {section.lessons.map((lesson) => { + const done = completedLessonIds.has(lesson.id); + return ( +
+
+
+
+ {lesson.lesson_type || 'lesson'} + {lesson.is_preview && preview} +
+

{lesson.title}

+
+ {lesson.duration_minutes || 0} мин + {done ? 'Завершён' : 'Не завершён'} +
+
+ {detail.enrollment ? ( + toggleLesson(lesson.id, !done)} + disabled={isSubmitting} + color={done ? 'success' : 'info'} + label={done ? 'Сбросить статус' : 'Отметить завершённым'} + className="justify-center lg:min-w-[220px]" + /> + ) : ( +
+ Запишитесь на курс, чтобы отмечать прогресс. +
+ )} +
+
+ ); + })} +
+
+ )) + )} +
+ +
+ +

Что уже работает

+
+
✓ Запись на курс из карточки курса
+
✓ Отображение структуры курса: секции и уроки
+
✓ Отметка завершения уроков с обновлением прогресса
+
+
+ + +

Быстрые переходы

+
+ + +
+
+
+
+ + ) : ( + +
+

Курс не найден

+

Вернитесь в Academy Hub и выберите другой курс.

+
+
+ )} + + + ); +} + +AcademyCoursePage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/academy/index.tsx b/frontend/src/pages/academy/index.tsx new file mode 100644 index 0000000..20e1ff1 --- /dev/null +++ b/frontend/src/pages/academy/index.tsx @@ -0,0 +1,359 @@ +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import axios from 'axios'; +import { mdiBookOpenPageVariant, mdiLoading, mdiMagnify, mdiSchoolOutline, mdiStarCircle, mdiViewDashboardOutline } from '@mdi/js'; +import BaseButton from '../../components/BaseButton'; +import BaseIcon from '../../components/BaseIcon'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import NotificationBar from '../../components/NotificationBar'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { useAppSelector } from '../../stores/hooks'; + +interface TopicItem { + id: string; + title: string; + summary?: string; + category?: { id: string; title: string } | null; +} + +interface CourseItem { + id: string; + title: string; + short_description?: string; + description?: string; + level?: string; + language?: string; + is_free?: boolean; + price?: string | number; + status?: string; + topic?: TopicItem | null; + instructor?: { firstName?: string; lastName?: string } | null; + thumbnail?: Array<{ publicUrl?: string }>; + isEnrolled?: boolean; + progressPercent?: number; + totalLessons?: number; + completedLessons?: number; +} + +interface LearningItem { + id: string; + status: string; + enrolled_at?: string; + course: CourseItem; + progressPercent: number; + totalLessons: number; + completedLessons: number; +} + +interface OverviewResponse { + stats: { + activeCourses: number; + completedCourses: number; + totalLessonsCompleted: number; + }; + topics: TopicItem[]; + catalog: CourseItem[]; + myLearning: LearningItem[]; +} + +function humanizeLevel(level?: string) { + if (!level) return 'Любой уровень'; + return level.replace(/_/g, ' '); +} + +function formatPrice(course: CourseItem) { + if (course.is_free) { + return 'Бесплатно'; + } + + if (course.price === null || course.price === undefined || course.price === '') { + return 'По запросу'; + } + + return `${course.price} ₽`; +} + +function progressTone(progress: number) { + if (progress >= 100) return 'bg-emerald-500'; + if (progress >= 50) return 'bg-sky-500'; + return 'bg-violet-500'; +} + +export default function AcademyHubPage() { + const { currentUser } = useAppSelector((state) => state.auth); + const [overview, setOverview] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [selectedTopicId, setSelectedTopicId] = useState('all'); + const [search, setSearch] = useState(''); + + useEffect(() => { + const fetchOverview = async () => { + try { + setIsLoading(true); + const response = await axios.get('/learning-hub/overview'); + setOverview(response.data); + setErrorMessage(''); + } catch (error) { + const message = axios.isAxiosError(error) + ? error.response?.data || 'Не удалось загрузить Academy Hub.' + : 'Не удалось загрузить Academy Hub.'; + setErrorMessage(String(message)); + } finally { + setIsLoading(false); + } + }; + + fetchOverview().then(); + }, []); + + const filteredCatalog = useMemo(() => { + const source = overview?.catalog ?? []; + return source.filter((course) => { + const matchesTopic = selectedTopicId === 'all' || course.topic?.id === selectedTopicId; + const matchesSearch = !search.trim() + || `${course.title} ${course.short_description || ''} ${course.topic?.title || ''}` + .toLowerCase() + .includes(search.trim().toLowerCase()); + return matchesTopic && matchesSearch; + }); + }, [overview?.catalog, search, selectedTopicId]); + + const greeting = currentUser?.firstName ? `Привет, ${currentUser.firstName}` : 'Привет'; + + return ( + <> + + {getPageTitle('Academy Hub')} + + + + + + + {errorMessage && ( + + {errorMessage} + + )} + + {successMessage && ( + + {successMessage} + + )} + + +
+
+

Личный кабинет обучения

+

{greeting}. Ваша точка входа в курсы по саморазвитию.

+

+ Изучайте каталог по подтемам, записывайтесь на курсы и возвращайтесь к личному треку обучения, + где уже видно ваш прогресс по урокам. +

+
+
+ {[ + { label: 'Активные курсы', value: overview?.stats.activeCourses ?? 0 }, + { label: 'Пройдено уроков', value: overview?.stats.totalLessonsCompleted ?? 0 }, + { label: 'Завершено курсов', value: overview?.stats.completedCourses ?? 0 }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+
+
+ +
+ +
+
+

Каталог

+

Найдите курс по подтеме

+
+
+
+ + setSearch(event.target.value)} + className="w-full bg-transparent text-sm outline-none placeholder:text-slate-400 md:w-56" + placeholder="Поиск по курсам" + /> +
+ +
+
+ +
+ {(overview?.topics ?? []).slice(0, 6).map((topic) => { + const active = selectedTopicId === topic.id; + return ( + + ); + })} +
+
+ + +

Как пользоваться

+
+
1. Откройте курс и посмотрите программу.
+
2. Нажмите «Записаться», чтобы добавить курс в личный кабинет.
+
3. Отмечайте завершённые уроки и наблюдайте рост прогресса.
+
+
+
+ + {isLoading ? ( + +
+ + Загружаем курсы и ваш прогресс... +
+
+ ) : ( +
+
+
+

Рекомендованные курсы

+ {filteredCatalog.length} найдено +
+ + {filteredCatalog.length === 0 ? ( + +
+

Ничего не найдено

+

Сбросьте фильтр или попробуйте другой поисковый запрос.

+
+
+ ) : ( +
+ {filteredCatalog.map((course) => { + const thumb = course.thumbnail?.[0]?.publicUrl; + return ( + + +
+
+
+ {course.topic?.title && {course.topic.title}} + {humanizeLevel(course.level)} + {formatPrice(course)} +
+
+

{course.title}

+

{course.short_description || course.description || 'Описание курса появится здесь.'}

+
+
+ {course.instructor?.firstName ? `${course.instructor.firstName} ${course.instructor.lastName || ''}`.trim() : 'MindSpring Coach'} + + {course.isEnrolled ? 'Уже в кабинете' : 'Открыть программу'} + +
+
+ + + ); + })} +
+ )} +
+ +
+
+
+ +
+
+

Моё обучение

+

Ваши курсы и текущий прогресс.

+
+
+ + {(overview?.myLearning ?? []).length === 0 ? ( + +
+

Пока нет записей на курсы

+

Откройте любой курс слева и добавьте его в личный кабинет.

+
+
+ ) : ( +
+ {(overview?.myLearning ?? []).map((item) => ( + + +
+
+
+ {item.course.topic?.title && {item.course.topic.title}} + {item.status} +
+

{item.course.title}

+

{item.course.short_description || 'Откройте курс, чтобы посмотреть программу и продолжить обучение.'}

+
+
+
{item.completedLessons}/{item.totalLessons || 0} уроков
+
{item.progressPercent}%
+
+
+
+
+
+ + + ))} +
+ )} +
+
+ )} + + +
+
+

Следующий шаг

+

Откройте курс и запустите свой первый трек

+
+
+ + Программа курса, запись и отметка прогресса уже работают end-to-end. +
+
+
+ + + ); +} + +AcademyHubPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 254a0a9..a93147e 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,218 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import React, { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; +import { mdiArrowRight, mdiChartTimelineVariant, mdiCompassOutline, mdiLogin, mdiSchoolOutline, mdiStarFourPoints, mdiViewDashboardOutline } from '@mdi/js'; import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; +import BaseIcon from '../components/BaseIcon'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const topics = [ + 'Осознанность и фокус', + 'Продуктивность без выгорания', + 'Коммуникация и уверенность', + 'Карьерный рост и навыки будущего', +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'App Preview' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; +const journey = [ + { + title: 'Выберите подтему', + text: 'Изучайте каталог по направлениям саморазвития и быстро находите нужный курс.', + }, + { + title: 'Добавьте курс в кабинет', + text: 'После авторизации вы можете записаться на курс и сразу получить личную траекторию.', + }, + { + title: 'Отмечайте прогресс', + text: 'Проходите уроки по шагам и отслеживайте, сколько уже завершено.', + }, +]; +export default function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('MindSpring Academy')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+
+ + + + + MindSpring + + + +
+ +
+
+
+ + Платформа курсов по саморазвитию для осознанного роста +
+ +

+ Саморазвитие, которое превращается + + в реальную привычку. + +

+ +

+ MindSpring помогает пользователям находить курсы по подтемам, записываться на обучение, + проходить уроки в личном кабинете и видеть свой прогресс в одном красивом интерфейсе. +

+ +
+ + + +
+ +
+ Авторизация пользователей + Каталог по подтемам + Личный кабинет и прогресс +
+
+ +
+
+
+
+

Личный кабинет

+

Ваш маршрут роста

+
+ MVP ready +
+
+ {[ + { label: 'Активные курсы', value: '04' }, + { label: 'Уроки закрыты', value: '18' }, + { label: 'Фокус недели', value: '76%' }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+
+
+ + Прогресс синхронизируется после записи на курс и отметки завершённых уроков. +
+
+
+ +
+
+
+ +
+
+

Каталог по подтемам

+

Подборка популярных направлений для старта

+
+
+
+ {topics.map((topic, index) => ( +
+
0{index + 1}
+
{topic}
+
+ ))} +
+
+
- - - +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+ {[ + { + title: 'Осознанная продуктивность', + text: 'Курсы про привычки, фокус, планирование и восстановление энергии.', + }, + { + title: 'Коммуникация и лидерство', + text: 'Подтемы для уверенного общения, презентаций и влияния без давления.', + }, + { + title: 'Карьера и мышление роста', + text: 'Практика для новых навыков, адаптивности и устойчивого карьерного развития.', + }, + ].map((card) => ( +
+
+

{card.title}

+

{card.text}

+
+ ))} +
+
-
+
+
+

Первый пользовательский сценарий

+

От поиска курса до видимого прогресса

+
+
+ {journey.map((step, index) => ( +
+
Шаг 0{index + 1}
+

{step.title}

+

{step.text}

+
+ ))} +
+
+ +
+
+
+
+

Личный кабинет

+

После входа пользователь получает Academy Hub

+

+ В первой итерации уже доступен реальный рабочий сценарий: каталог курсов, фильтрация по темам, + запись на курс, просмотр программы и отметка прогресса по урокам. +

+
+
+
+
✓ авторизация и кабинет пользователя
+
✓ просмотр курсов по подтемам
+
✓ список «Моё обучение»
+
✓ трекинг завершённых уроков
+
+
+ + +
+
+
+
+
+ + ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; -