This commit is contained in:
Flatlogic Bot 2026-04-01 10:22:18 +00:00
parent 1746dff8e4
commit bc7c2052ae
8 changed files with 1492 additions and 151 deletions

View File

@ -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',

View 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;

View File

@ -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'

View File

@ -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'

View File

@ -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',

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

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

View File

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