hhhh
This commit is contained in:
parent
1746dff8e4
commit
bc7c2052ae
@ -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',
|
||||
|
||||
548
backend/src/routes/learningHub.js
Normal file
548
backend/src/routes/learningHub.js
Normal file
@ -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;
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
376
frontend/src/pages/academy/[courseId].tsx
Normal file
376
frontend/src/pages/academy/[courseId].tsx
Normal file
@ -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<DetailResponse | null>(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<string>();
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle(detail?.course.title || 'Course')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiSchoolOutline} title={detail?.course.title || 'Course'} main>
|
||||
<BaseButton href="/academy" color="whiteDark" label="Назад к Academy Hub" icon={mdiArrowLeft} />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{errorMessage && (
|
||||
<NotificationBar color="danger">
|
||||
{errorMessage}
|
||||
</NotificationBar>
|
||||
)}
|
||||
{successMessage && (
|
||||
<NotificationBar color="success">
|
||||
{successMessage}
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<CardBox cardBoxClassName="p-8">
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<BaseIcon path={mdiLoading} className="animate-spin" size={24} />
|
||||
Загружаем программу курса...
|
||||
</div>
|
||||
</CardBox>
|
||||
) : detail ? (
|
||||
<>
|
||||
<CardBox className="mb-6 overflow-hidden !border-0 bg-slate-950 text-white shadow-[0_24px_70px_rgba(15,23,42,0.24)]" cardBoxClassName="p-0">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="min-h-[320px] p-8 lg:p-10" style={heroStyle}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detail.course.topic?.title && <span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-cyan-100">{detail.course.topic.title}</span>}
|
||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">{humanizeLevel(detail.course.level)}</span>
|
||||
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs font-semibold text-emerald-100">{formatPrice(detail.course)}</span>
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-3xl text-4xl font-black tracking-[-0.04em]">{detail.course.title}</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-200">{detail.course.short_description || detail.course.description || 'Подробное описание курса появится здесь.'}</p>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-5 text-sm text-slate-200">
|
||||
<span>{detail.course.instructor?.firstName ? `${detail.course.instructor.firstName} ${detail.course.instructor.lastName || ''}`.trim() : 'MindSpring Coach'}</span>
|
||||
<span>{detail.progress.completedLessons}/{detail.progress.totalLessons} уроков закрыто</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between bg-white p-8 text-slate-900 lg:p-10">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-violet-600">Текущий статус</p>
|
||||
<div className="mt-5 rounded-[28px] bg-slate-50 p-5">
|
||||
<div className="flex items-center justify-between text-sm text-slate-500">
|
||||
<span>Прогресс по курсу</span>
|
||||
<span className="font-semibold text-slate-900">{detail.progress.progressPercent}%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-3 rounded-full bg-slate-200">
|
||||
<div className="h-3 rounded-full bg-gradient-to-r from-violet-600 to-sky-500" style={{ width: `${detail.progress.progressPercent}%` }} />
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-slate-600">{detail.progress.completedLessons} из {detail.progress.totalLessons} уроков отмечены как завершённые.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{detail.enrollment ? (
|
||||
<>
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm font-medium text-emerald-700">
|
||||
Курс уже находится в вашем личном кабинете.
|
||||
</div>
|
||||
<BaseButton href="/academy" color="success" label="Вернуться к моему обучению" className="w-full justify-center !py-3" />
|
||||
</>
|
||||
) : (
|
||||
<BaseButton
|
||||
onClick={handleEnroll}
|
||||
disabled={isSubmitting}
|
||||
color="info"
|
||||
label={isSubmitting ? 'Добавляем...' : 'Записаться на курс'}
|
||||
className="w-full justify-center !py-3 shadow-lg shadow-sky-200/60"
|
||||
/>
|
||||
)}
|
||||
<Link href="/academy" className="flex items-center justify-center gap-2 text-sm font-medium text-slate-500 transition hover:text-slate-900">
|
||||
<BaseIcon path={mdiArrowLeft} size={16} />
|
||||
Назад к подборке курсов
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="space-y-5">
|
||||
{detail.sections.length === 0 ? (
|
||||
<CardBox cardBoxClassName="p-8">
|
||||
<div className="text-center text-slate-500">
|
||||
<p className="text-lg font-semibold text-slate-900">Программа пока не заполнена</p>
|
||||
<p className="mt-2">Добавьте секции и уроки в админке, чтобы наполнить курс.</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : (
|
||||
detail.sections.map((section, index) => (
|
||||
<CardBox key={section.id} cardBoxClassName="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-violet-600">Секция {index + 1}</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-slate-900">{section.title}</h2>
|
||||
{section.summary && <p className="mt-2 text-sm leading-7 text-slate-600">{section.summary}</p>}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-100 px-4 py-2 text-sm text-slate-600">{section.lessons.length} уроков</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{section.lessons.map((lesson) => {
|
||||
const done = completedLessonIds.has(lesson.id);
|
||||
return (
|
||||
<div key={lesson.id} className="rounded-[24px] border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600">{lesson.lesson_type || 'lesson'}</span>
|
||||
{lesson.is_preview && <span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">preview</span>}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold text-slate-900">{lesson.title}</h3>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-slate-500">
|
||||
<span className="inline-flex items-center gap-2"><BaseIcon path={mdiClockOutline} size={16} /> {lesson.duration_minutes || 0} мин</span>
|
||||
<span className="inline-flex items-center gap-2"><BaseIcon path={done ? mdiCheckCircle : mdiPlayCircleOutline} size={16} className={done ? 'text-emerald-500' : 'text-violet-500'} /> {done ? 'Завершён' : 'Не завершён'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{detail.enrollment ? (
|
||||
<BaseButton
|
||||
onClick={() => toggleLesson(lesson.id, !done)}
|
||||
disabled={isSubmitting}
|
||||
color={done ? 'success' : 'info'}
|
||||
label={done ? 'Сбросить статус' : 'Отметить завершённым'}
|
||||
className="justify-center lg:min-w-[220px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-500">
|
||||
Запишитесь на курс, чтобы отмечать прогресс.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBox>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<CardBox cardBoxClassName="p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-violet-600">Что уже работает</p>
|
||||
<div className="mt-4 space-y-4 text-sm leading-7 text-slate-600">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">✓ Запись на курс из карточки курса</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">✓ Отображение структуры курса: секции и уроки</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">✓ Отметка завершения уроков с обновлением прогресса</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox cardBoxClassName="p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-600">Быстрые переходы</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<BaseButton href="/academy" color="info" label="Открыть Academy Hub" className="w-full justify-center" />
|
||||
<BaseButton href="/dashboard" color="success" label="Открыть админ-панель" className="w-full justify-center" />
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CardBox cardBoxClassName="p-8">
|
||||
<div className="text-center text-slate-500">
|
||||
<p className="text-lg font-semibold text-slate-900">Курс не найден</p>
|
||||
<p className="mt-2">Вернитесь в Academy Hub и выберите другой курс.</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AcademyCoursePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
359
frontend/src/pages/academy/index.tsx
Normal file
359
frontend/src/pages/academy/index.tsx
Normal file
@ -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<OverviewResponse | null>(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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Academy Hub')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiSchoolOutline} title="Academy Hub" main>
|
||||
<BaseButton href="/dashboard" color="success" label="Admin" icon={mdiViewDashboardOutline} />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{errorMessage && (
|
||||
<NotificationBar color="danger">
|
||||
{errorMessage}
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<NotificationBar color="success">
|
||||
{successMessage}
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
<CardBox className="mb-6 overflow-hidden !border-0 bg-gradient-to-r from-[#0F172A] via-[#1E1B4B] to-[#0F766E] text-white shadow-[0_24px_70px_rgba(15,23,42,0.24)]" cardBoxClassName="p-0">
|
||||
<div className="grid gap-8 p-8 lg:grid-cols-[1.15fr_0.85fr] lg:p-10">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-200">Личный кабинет обучения</p>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-[-0.04em]">{greeting}. Ваша точка входа в курсы по саморазвитию.</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-200">
|
||||
Изучайте каталог по подтемам, записывайтесь на курсы и возвращайтесь к личному треку обучения,
|
||||
где уже видно ваш прогресс по урокам.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Активные курсы', value: overview?.stats.activeCourses ?? 0 },
|
||||
{ label: 'Пройдено уроков', value: overview?.stats.totalLessonsCompleted ?? 0 },
|
||||
{ label: 'Завершено курсов', value: overview?.stats.completedCourses ?? 0 },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-[24px] border border-white/10 bg-white/10 p-4 backdrop-blur">
|
||||
<div className="text-3xl font-bold">{item.value}</div>
|
||||
<div className="mt-2 text-sm text-slate-200">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="mb-6 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<CardBox className="bg-white/90" cardBoxClassName="p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-violet-600">Каталог</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-slate-900">Найдите курс по подтеме</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row">
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<BaseIcon path={mdiMagnify} className="text-slate-400" size={18} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-slate-400 md:w-56"
|
||||
placeholder="Поиск по курсам"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedTopicId}
|
||||
onChange={(event) => setSelectedTopicId(event.target.value)}
|
||||
className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700 outline-none transition focus:border-violet-400"
|
||||
>
|
||||
<option value="all">Все подтемы</option>
|
||||
{(overview?.topics ?? []).map((topic) => (
|
||||
<option key={topic.id} value={topic.id}>{topic.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{(overview?.topics ?? []).slice(0, 6).map((topic) => {
|
||||
const active = selectedTopicId === topic.id;
|
||||
return (
|
||||
<button
|
||||
key={topic.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTopicId(active ? 'all' : topic.id)}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-medium transition ${active ? 'border-violet-600 bg-violet-50 text-violet-700' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900'}`}
|
||||
>
|
||||
{topic.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="bg-white/90" cardBoxClassName="p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-600">Как пользоваться</p>
|
||||
<div className="mt-4 space-y-4 text-sm leading-7 text-slate-600">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">1. Откройте курс и посмотрите программу.</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">2. Нажмите «Записаться», чтобы добавить курс в личный кабинет.</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">3. Отмечайте завершённые уроки и наблюдайте рост прогресса.</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<CardBox className="mb-6" cardBoxClassName="p-8">
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<BaseIcon path={mdiLoading} className="animate-spin" size={24} />
|
||||
Загружаем курсы и ваш прогресс...
|
||||
</div>
|
||||
</CardBox>
|
||||
) : (
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-slate-900">Рекомендованные курсы</h2>
|
||||
<span className="text-sm text-slate-500">{filteredCatalog.length} найдено</span>
|
||||
</div>
|
||||
|
||||
{filteredCatalog.length === 0 ? (
|
||||
<CardBox cardBoxClassName="p-8">
|
||||
<div className="text-center text-slate-500">
|
||||
<p className="text-lg font-semibold text-slate-900">Ничего не найдено</p>
|
||||
<p className="mt-2">Сбросьте фильтр или попробуйте другой поисковый запрос.</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{filteredCatalog.map((course) => {
|
||||
const thumb = course.thumbnail?.[0]?.publicUrl;
|
||||
return (
|
||||
<Link key={course.id} href={`/academy/${course.id}`} className="block">
|
||||
<CardBox className="h-full cursor-pointer transition-transform duration-200 hover:-translate-y-1 hover:shadow-[0_20px_60px_rgba(15,23,42,0.12)]" cardBoxClassName="p-0">
|
||||
<div
|
||||
className="h-44 rounded-t-2xl bg-gradient-to-br from-violet-500 via-sky-400 to-emerald-400"
|
||||
style={thumb ? { backgroundImage: `linear-gradient(135deg, rgba(15,23,42,0.15), rgba(15,23,42,0.45)), url(${thumb})`, backgroundSize: 'cover', backgroundPosition: 'center' } : undefined}
|
||||
/>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{course.topic?.title && <span className="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">{course.topic.title}</span>}
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{humanizeLevel(course.level)}</span>
|
||||
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">{formatPrice(course)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold text-slate-900">{course.title}</h3>
|
||||
<p className="mt-3 line-clamp-3 text-sm leading-7 text-slate-600">{course.short_description || course.description || 'Описание курса появится здесь.'}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-slate-500">
|
||||
<span>{course.instructor?.firstName ? `${course.instructor.firstName} ${course.instructor.lastName || ''}`.trim() : 'MindSpring Coach'}</span>
|
||||
<span className={`rounded-full px-3 py-1 font-semibold ${course.isEnrolled ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{course.isEnrolled ? 'Уже в кабинете' : 'Открыть программу'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-100 text-sky-700">
|
||||
<BaseIcon path={mdiBookOpenPageVariant} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Моё обучение</h2>
|
||||
<p className="text-sm text-slate-500">Ваши курсы и текущий прогресс.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(overview?.myLearning ?? []).length === 0 ? (
|
||||
<CardBox cardBoxClassName="p-8">
|
||||
<div className="text-center text-slate-500">
|
||||
<p className="text-lg font-semibold text-slate-900">Пока нет записей на курсы</p>
|
||||
<p className="mt-2">Откройте любой курс слева и добавьте его в личный кабинет.</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(overview?.myLearning ?? []).map((item) => (
|
||||
<Link key={item.id} href={`/academy/${item.course.id}`} className="block">
|
||||
<CardBox className="cursor-pointer transition hover:shadow-[0_18px_50px_rgba(15,23,42,0.10)]" cardBoxClassName="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.course.topic?.title && <span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{item.course.topic.title}</span>}
|
||||
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">{item.status}</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-xl font-semibold text-slate-900">{item.course.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">{item.course.short_description || 'Откройте курс, чтобы посмотреть программу и продолжить обучение.'}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-slate-500">
|
||||
<div>{item.completedLessons}/{item.totalLessons || 0} уроков</div>
|
||||
<div className="mt-2 font-semibold text-slate-900">{item.progressPercent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 rounded-full bg-slate-100">
|
||||
<div className={`h-2 rounded-full ${progressTone(item.progressPercent)}`} style={{ width: `${item.progressPercent}%` }} />
|
||||
</div>
|
||||
</CardBox>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardBox className="mt-6" cardBoxClassName="p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-violet-600">Следующий шаг</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-slate-900">Откройте курс и запустите свой первый трек</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||
<BaseIcon path={mdiStarCircle} className="text-violet-500" size={18} />
|
||||
Программа курса, запись и отметка прогресса уже работают end-to-end.
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AcademyHubPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
@ -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) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const journey = [
|
||||
{
|
||||
title: 'Выберите подтему',
|
||||
text: 'Изучайте каталог по направлениям саморазвития и быстро находите нужный курс.',
|
||||
},
|
||||
{
|
||||
title: 'Добавьте курс в кабинет',
|
||||
text: 'После авторизации вы можете записаться на курс и сразу получить личную траекторию.',
|
||||
},
|
||||
{
|
||||
title: 'Отмечайте прогресс',
|
||||
text: 'Проходите уроки по шагам и отслеживайте, сколько уже завершено.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('MindSpring Academy')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<main className="min-h-screen bg-[#F7F8FC] text-slate-900">
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(124,58,237,0.22),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(14,165,233,0.18),_transparent_30%)]" />
|
||||
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-6 lg:px-10">
|
||||
<header className="flex items-center justify-between rounded-full border border-white/70 bg-white/80 px-4 py-3 shadow-[0_10px_40px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||
<Link href="/" className="flex items-center gap-3 text-sm font-semibold tracking-[0.24em] text-slate-900 uppercase">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-[#7C3AED] text-white shadow-lg shadow-violet-300/50">
|
||||
<BaseIcon path={mdiStarFourPoints} size={18} />
|
||||
</span>
|
||||
MindSpring
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-8 text-sm text-slate-600 md:flex">
|
||||
<a href="#topics" className="transition hover:text-slate-900">Подтемы</a>
|
||||
<a href="#journey" className="transition hover:text-slate-900">Как это работает</a>
|
||||
<a href="#cabinet" className="transition hover:text-slate-900">Личный кабинет</a>
|
||||
<Link href="/login" className="font-semibold text-[#7C3AED]">Login</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="grid flex-1 items-center gap-12 py-16 lg:grid-cols-[1.1fr_0.9fr] lg:py-20">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-4 py-2 text-sm font-medium text-violet-700">
|
||||
<BaseIcon path={mdiCompassOutline} size={16} />
|
||||
Платформа курсов по саморазвитию для осознанного роста
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-black leading-[1.05] tracking-[-0.04em] text-slate-950 md:text-6xl">
|
||||
Саморазвитие, которое превращается
|
||||
<span className="block bg-gradient-to-r from-[#7C3AED] via-[#0EA5E9] to-[#14B8A6] bg-clip-text text-transparent">
|
||||
в реальную привычку.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-xl text-lg leading-8 text-slate-600">
|
||||
MindSpring помогает пользователям находить курсы по подтемам, записываться на обучение,
|
||||
проходить уроки в личном кабинете и видеть свой прогресс в одном красивом интерфейсе.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-wrap gap-4">
|
||||
<BaseButton href="/register" color="info" label="Создать аккаунт" icon={mdiArrowRight} className="!px-6 !py-3 shadow-lg shadow-sky-200/60" />
|
||||
<BaseButton href="/login" color="whiteDark" label="Войти в кабинет" icon={mdiLogin} className="!px-6 !py-3" />
|
||||
<BaseButton href="/dashboard" color="success" label="Admin interface" icon={mdiViewDashboardOutline} className="!px-6 !py-3" />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-wrap gap-3 text-sm text-slate-500">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-4 py-2">Авторизация пользователей</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-4 py-2">Каталог по подтемам</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-4 py-2">Личный кабинет и прогресс</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5">
|
||||
<div className="rounded-[28px] border border-white/80 bg-slate-950 p-6 text-white shadow-[0_24px_80px_rgba(15,23,42,0.22)]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-violet-200">Личный кабинет</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold">Ваш маршрут роста</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs text-violet-100">MVP ready</span>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Активные курсы', value: '04' },
|
||||
{ label: 'Уроки закрыты', value: '18' },
|
||||
{ label: 'Фокус недели', value: '76%' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-2xl font-bold">{item.value}</div>
|
||||
<div className="mt-2 text-sm text-slate-300">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl bg-gradient-to-r from-violet-500/20 to-sky-400/20 p-4">
|
||||
<div className="flex items-center gap-3 text-sm text-slate-200">
|
||||
<BaseIcon path={mdiChartTimelineVariant} size={18} className="text-sky-300" />
|
||||
Прогресс синхронизируется после записи на курс и отметки завершённых уроков.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-slate-200 bg-white p-6 shadow-[0_18px_50px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700">
|
||||
<BaseIcon path={mdiSchoolOutline} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Каталог по подтемам</p>
|
||||
<p className="text-sm text-slate-500">Подборка популярных направлений для старта</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{topics.map((topic, index) => (
|
||||
<div key={topic} className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">0{index + 1}</div>
|
||||
<div className="mt-2 font-semibold text-slate-900">{topic}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<section id="topics" className="mx-auto max-w-7xl px-6 py-6 lg:px-10 lg:py-10">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
title: 'Осознанная продуктивность',
|
||||
text: 'Курсы про привычки, фокус, планирование и восстановление энергии.',
|
||||
},
|
||||
{
|
||||
title: 'Коммуникация и лидерство',
|
||||
text: 'Подтемы для уверенного общения, презентаций и влияния без давления.',
|
||||
},
|
||||
{
|
||||
title: 'Карьера и мышление роста',
|
||||
text: 'Практика для новых навыков, адаптивности и устойчивого карьерного развития.',
|
||||
},
|
||||
].map((card) => (
|
||||
<div key={card.title} className="rounded-[28px] border border-slate-200 bg-white p-8 shadow-[0_18px_50px_rgba(15,23,42,0.05)]">
|
||||
<div className="mb-4 h-12 w-12 rounded-2xl bg-gradient-to-br from-violet-500 to-sky-400" />
|
||||
<h3 className="text-2xl font-semibold text-slate-900">{card.title}</h3>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">{card.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<section id="journey" className="mx-auto max-w-7xl px-6 py-16 lg:px-10">
|
||||
<div className="mb-8 max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-violet-600">Первый пользовательский сценарий</p>
|
||||
<h2 className="mt-4 text-4xl font-bold tracking-[-0.03em] text-slate-950">От поиска курса до видимого прогресса</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{journey.map((step, index) => (
|
||||
<div key={step.title} className="rounded-[28px] border border-slate-200 bg-white p-8 shadow-[0_18px_50px_rgba(15,23,42,0.05)]">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.24em] text-slate-400">Шаг 0{index + 1}</div>
|
||||
<h3 className="mt-5 text-2xl font-semibold text-slate-900">{step.title}</h3>
|
||||
<p className="mt-3 leading-7 text-slate-600">{step.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="cabinet" className="mx-auto max-w-7xl px-6 pb-20 lg:px-10">
|
||||
<div className="overflow-hidden rounded-[36px] bg-slate-950 px-8 py-12 text-white shadow-[0_28px_90px_rgba(15,23,42,0.25)] lg:px-12">
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_0.8fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-300">Личный кабинет</p>
|
||||
<h2 className="mt-4 text-4xl font-bold tracking-[-0.03em]">После входа пользователь получает Academy Hub</h2>
|
||||
<p className="mt-5 max-w-2xl text-lg leading-8 text-slate-300">
|
||||
В первой итерации уже доступен реальный рабочий сценарий: каталог курсов, фильтрация по темам,
|
||||
запись на курс, просмотр программы и отметка прогресса по урокам.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/5 p-6">
|
||||
<div className="space-y-4 text-sm text-slate-200">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">✓ авторизация и кабинет пользователя</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">✓ просмотр курсов по подтемам</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">✓ список «Моё обучение»</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">✓ трекинг завершённых уроков</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-4">
|
||||
<BaseButton href="/login" color="info" label="Открыть кабинет" className="!px-6 !py-3" />
|
||||
<BaseButton href="/dashboard" color="whiteDark" label="Перейти в админку" className="!px-6 !py-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user