Dev #11
@@ -161,7 +161,19 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
|
||||||
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
|
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
|
||||||
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0, 0, 100));
|
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
|
||||||
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
|
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
|
||||||
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
|
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
|
||||||
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
|
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using UniVerse.Application.DTOs.Lectures;
|
|||||||
using UniVerse.Application.DTOs.Notifications;
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Entities;
|
using UniVerse.Domain.Entities;
|
||||||
|
using UniVerse.Domain.Exceptions;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -92,6 +93,79 @@ public class LectureServiceTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 3)]
|
||||||
|
[InlineData(2, 3)]
|
||||||
|
[InlineData(3, 5)]
|
||||||
|
[InlineData(4, 7)]
|
||||||
|
[InlineData(5, 7)]
|
||||||
|
public async Task EnrollAsync_ThrowsWhenActiveEnrollmentLimitReached(int level, int activeEnrollments)
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var gamification = Substitute.For<IGamificationService>();
|
||||||
|
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(level);
|
||||||
|
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
|
||||||
|
var startsAt = DateTime.UtcNow.AddDays(1);
|
||||||
|
|
||||||
|
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
db.Lectures.Add(Lecture(100, startsAt.AddDays(100)));
|
||||||
|
for (var i = 1; i <= activeEnrollments; i++)
|
||||||
|
{
|
||||||
|
db.Lectures.Add(Lecture(i, startsAt.AddDays(i)));
|
||||||
|
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var gamification = Substitute.For<IGamificationService>();
|
||||||
|
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
|
||||||
|
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
db.Lectures.Add(Lecture(100, now.AddDays(1)));
|
||||||
|
for (var i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
|
||||||
|
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var gamification = Substitute.For<IGamificationService>();
|
||||||
|
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
|
||||||
|
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
db.Lectures.Add(Lecture(100, now.AddDays(1)));
|
||||||
|
for (var i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
|
||||||
|
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1, Attended = true });
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await service.EnrollAsync(100, 1);
|
||||||
|
|
||||||
|
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UnenrollAsync_CancelsLectureReminders()
|
public async Task UnenrollAsync_CancelsLectureReminders()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ public class UserServiceTests
|
|||||||
Assert.Null(stats.NextLevelXp);
|
Assert.Null(stats.NextLevelXp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_ReturnsEnrollmentSlotStateAndRules()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
SeedLevelThresholds(db);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
db.Lectures.AddRange(
|
||||||
|
Lecture(1, now.AddDays(1)),
|
||||||
|
Lecture(2, now.AddDays(2)),
|
||||||
|
Lecture(3, now.AddDays(-1)));
|
||||||
|
db.LectureEnrollments.AddRange(
|
||||||
|
new LectureEnrollment { LectureId = 1, UserId = 1 },
|
||||||
|
new LectureEnrollment { LectureId = 2, UserId = 1 },
|
||||||
|
new LectureEnrollment { LectureId = 3, UserId = 1 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = CreateService(db);
|
||||||
|
|
||||||
|
var stats = await service.GetStatsAsync(1);
|
||||||
|
|
||||||
|
Assert.Equal(3, stats.ActiveEnrollments);
|
||||||
|
Assert.Equal(5, stats.EnrollmentSlotLimit);
|
||||||
|
Assert.Equal(new[] { 1, 3, 4 }, stats.EnrollmentSlotRules.Select(rule => rule.Level));
|
||||||
|
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots));
|
||||||
|
}
|
||||||
|
|
||||||
private static AppDbContext CreateDbContext()
|
private static AppDbContext CreateDbContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
@@ -77,4 +104,15 @@ public class UserServiceTests
|
|||||||
new LevelThreshold { Level = 3, RequiredXp = 300 });
|
new LevelThreshold { Level = 3, RequiredXp = 300 });
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Lecture Lecture(int id, DateTime startsAt) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
CourseId = 1,
|
||||||
|
Title = $"Lecture {id}",
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = startsAt.AddHours(2),
|
||||||
|
IsOpen = true,
|
||||||
|
MaxEnrollments = 30
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5135,6 +5135,20 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"EnrollmentSlotRuleDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"level": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"LectureDetailDto": {
|
"LectureDetailDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6104,6 +6118,21 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"activeEnrollments": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"enrollmentSlotLimit": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"enrollmentSlotRules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EnrollmentSlotRuleDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ public record UserStatsDto(
|
|||||||
int Level,
|
int Level,
|
||||||
int AchievementsCount,
|
int AchievementsCount,
|
||||||
int CurrentLevelXp,
|
int CurrentLevelXp,
|
||||||
int? NextLevelXp
|
int? NextLevelXp,
|
||||||
|
int ActiveEnrollments,
|
||||||
|
int EnrollmentSlotLimit,
|
||||||
|
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
||||||
|
|
||||||
public record UpdateUserRequest(
|
public record UpdateUserRequest(
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? AvatarUrl
|
string? AvatarUrl
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace UniVerse.Domain.Services;
|
||||||
|
|
||||||
|
public static class EnrollmentSlotPolicy
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<EnrollmentSlotRule> SlotRules =
|
||||||
|
[
|
||||||
|
new(1, 3),
|
||||||
|
new(3, 5),
|
||||||
|
new(4, 7)
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<EnrollmentSlotRule> Rules => SlotRules;
|
||||||
|
|
||||||
|
public static int GetLimitForLevel(int level) =>
|
||||||
|
SlotRules
|
||||||
|
.Where(rule => rule.Level <= level)
|
||||||
|
.OrderBy(rule => rule.Level)
|
||||||
|
.LastOrDefault()?.Slots ?? SlotRules[0].Slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record EnrollmentSlotRule(int Level, int Slots);
|
||||||
@@ -6,6 +6,7 @@ using UniVerse.Application.Interfaces;
|
|||||||
using UniVerse.Application.Mappings;
|
using UniVerse.Application.Mappings;
|
||||||
using UniVerse.Domain.Entities;
|
using UniVerse.Domain.Entities;
|
||||||
using UniVerse.Domain.Exceptions;
|
using UniVerse.Domain.Exceptions;
|
||||||
|
using UniVerse.Domain.Services;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
|
|
||||||
namespace UniVerse.Infrastructure.Services;
|
namespace UniVerse.Infrastructure.Services;
|
||||||
@@ -124,6 +125,15 @@ public class LectureService : ILectureService
|
|||||||
throw new ConflictException("Lecture is full.");
|
throw new ConflictException("Lecture is full.");
|
||||||
if (lecture.Enrollments.Any(e => e.UserId == userId))
|
if (lecture.Enrollments.Any(e => e.UserId == userId))
|
||||||
throw new ConflictException("Already enrolled.");
|
throw new ConflictException("Already enrolled.");
|
||||||
|
|
||||||
|
var level = await _gamification.CalculateLevelAsync(user.Xp);
|
||||||
|
var enrollmentLimit = EnrollmentSlotPolicy.GetLimitForLevel(level);
|
||||||
|
var activeEnrollments = await _db.LectureEnrollments
|
||||||
|
.CountAsync(e => e.UserId == userId && !e.Attended);
|
||||||
|
|
||||||
|
if (activeEnrollments >= enrollmentLimit)
|
||||||
|
throw new ConflictException("Enrollment limit reached for your level.");
|
||||||
|
|
||||||
_db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId });
|
_db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId });
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
await ScheduleLectureRemindersAsync(lecture, user);
|
await ScheduleLectureRemindersAsync(lecture, user);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using UniVerse.Application.Interfaces;
|
|||||||
using UniVerse.Application.Mappings;
|
using UniVerse.Application.Mappings;
|
||||||
using UniVerse.Domain.Enums;
|
using UniVerse.Domain.Enums;
|
||||||
using UniVerse.Domain.Exceptions;
|
using UniVerse.Domain.Exceptions;
|
||||||
|
using UniVerse.Domain.Services;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
|
|
||||||
namespace UniVerse.Infrastructure.Services;
|
namespace UniVerse.Infrastructure.Services;
|
||||||
@@ -55,14 +56,21 @@ public class UserService : IUserService
|
|||||||
var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == id && e.Attended);
|
var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == id && e.Attended);
|
||||||
var reviews = await _db.Reviews.CountAsync(r => r.UserId == id);
|
var reviews = await _db.Reviews.CountAsync(r => r.UserId == id);
|
||||||
var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id);
|
var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id);
|
||||||
|
var activeEnrollments = await _db.LectureEnrollments
|
||||||
|
.CountAsync(e => e.UserId == id && !e.Attended);
|
||||||
|
|
||||||
var level = await _gamification.CalculateLevelAsync(user.Xp);
|
var level = await _gamification.CalculateLevelAsync(user.Xp);
|
||||||
var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp);
|
var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp);
|
||||||
|
var slotLimit = EnrollmentSlotPolicy.GetLimitForLevel(level);
|
||||||
|
var slotRules = EnrollmentSlotPolicy.Rules
|
||||||
|
.Select(rule => new EnrollmentSlotRuleDto(rule.Level, rule.Slots))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return new UserStatsDto(
|
return new UserStatsDto(
|
||||||
totalLectures, attended, reviews,
|
totalLectures, attended, reviews,
|
||||||
user.Xp, user.Coins, level, achievements,
|
user.Xp, user.Coins, level, achievements,
|
||||||
levelProgress.CurrentLevelXp, levelProgress.NextLevelXp
|
levelProgress.CurrentLevelXp, levelProgress.NextLevelXp,
|
||||||
|
activeEnrollments, slotLimit, slotRules
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export async function apiRequest<T>(
|
|||||||
const message =
|
const message =
|
||||||
typeof body === 'object' && body && 'message' in body
|
typeof body === 'object' && body && 'message' in body
|
||||||
? String((body as { message: unknown }).message)
|
? String((body as { message: unknown }).message)
|
||||||
|
: typeof body === 'object' && body && 'detail' in body
|
||||||
|
? String((body as { detail: unknown }).detail)
|
||||||
: `API request failed with status ${response.status}`
|
: `API request failed with status ${response.status}`
|
||||||
throw new ApiError(message, response.status, body)
|
throw new ApiError(message, response.status, body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?:
|
|||||||
lecturesAttended: stats?.attendedLectures ?? 0,
|
lecturesAttended: stats?.attendedLectures ?? 0,
|
||||||
hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0,
|
hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0,
|
||||||
achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [],
|
achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [],
|
||||||
|
activeEnrollments: stats?.activeEnrollments,
|
||||||
|
enrollmentSlotLimit: stats?.enrollmentSlotLimit,
|
||||||
|
enrollmentSlotRules: stats?.enrollmentSlotRules,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ export interface UserStatsDto {
|
|||||||
achievementsCount: number
|
achievementsCount: number
|
||||||
currentLevelXp: number
|
currentLevelXp: number
|
||||||
nextLevelXp?: number | null
|
nextLevelXp?: number | null
|
||||||
|
activeEnrollments: number
|
||||||
|
enrollmentSlotLimit: number
|
||||||
|
enrollmentSlotRules: EnrollmentSlotRuleDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnrollmentSlotRuleDto {
|
||||||
|
level: number
|
||||||
|
slots: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LectureDto {
|
export interface LectureDto {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ const isCoinDialogOpen = ref(false)
|
|||||||
const profileMenuRef = ref<HTMLElement | null>(null)
|
const profileMenuRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const unreadCount = computed(() => userStore.unreadCount())
|
const unreadCount = computed(() => userStore.unreadCount())
|
||||||
|
const enrollmentSlotText = computed(() => {
|
||||||
|
if (auth.user?.activeRole !== 'student') return ''
|
||||||
|
if (typeof auth.user.enrollmentSlotLimit !== 'number') return ''
|
||||||
|
return `${auth.user.activeEnrollments ?? 0}/${auth.user.enrollmentSlotLimit}`
|
||||||
|
})
|
||||||
const roleLabels: Record<UserRole, string> = {
|
const roleLabels: Record<UserRole, string> = {
|
||||||
student: 'Студент',
|
student: 'Студент',
|
||||||
teacher: 'Преподаватель',
|
teacher: 'Преподаватель',
|
||||||
@@ -85,6 +90,7 @@ function handleDocumentPointerDown(event: PointerEvent) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
|
if (auth.user?.roles.includes('student')) void userStore.fetchStats().catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -115,6 +121,11 @@ onBeforeUnmount(() => {
|
|||||||
<span class="level-value">{{ auth.user.level }}</span>
|
<span class="level-value">{{ auth.user.level }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="enrollmentSlotText" class="slot-chip" title="Занятые слоты записи / доступные слоты">
|
||||||
|
<AppIcon class="slot-icon" icon="calendar" :size="15" />
|
||||||
|
<span class="slot-value">{{ enrollmentSlotText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="notif-btn" type="button" aria-label="Уведомления" @click="$router.push('/notifications')">
|
<button class="notif-btn" type="button" aria-label="Уведомления" @click="$router.push('/notifications')">
|
||||||
<AppIcon icon="bell" :size="18" />
|
<AppIcon icon="bell" :size="18" />
|
||||||
<span class="notif-dot" v-if="auth.user && unreadCount > 0">
|
<span class="notif-dot" v-if="auth.user && unreadCount > 0">
|
||||||
@@ -277,6 +288,26 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
.slot-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--color-primary-a20);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--color-primary-a10);
|
||||||
|
color: var(--color-success-text);
|
||||||
|
cursor: default;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.slot-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.slot-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
.notif-btn {
|
.notif-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import ModalDialog from './ModalDialog.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMyLectures() {
|
||||||
|
close()
|
||||||
|
router.push('/my-lectures')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ModalDialog :model-value="modelValue" title="Лимит записей достигнут" @update:model-value="emit('update:modelValue', $event)">
|
||||||
|
<div class="limit-modal">
|
||||||
|
<p>
|
||||||
|
Все доступные слоты записи уже заняты. Чтобы записаться на новую лекцию, отмените одну из текущих записей
|
||||||
|
или повысьте уровень.
|
||||||
|
</p>
|
||||||
|
<div class="limit-actions">
|
||||||
|
<button class="btn-secondary" type="button" @click="close">Понятно</button>
|
||||||
|
<button class="btn-primary" type="button" @click="openMyLectures">Мои записи</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.limit-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.limit-modal p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.limit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Lecture } from '@/types'
|
import type { Lecture } from '@/types'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue'
|
import AppIcon from '@/components/ui/AppIcon.vue'
|
||||||
|
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
lecture: Lecture
|
lecture: Lecture
|
||||||
@@ -10,6 +13,11 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ register: [id: string] }>()
|
const emit = defineEmits<{ register: [id: string] }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const enrollmentLimitModalOpen = ref(false)
|
||||||
|
const isRegistrationLimitReached = computed(() =>
|
||||||
|
!props.registered && !userStore.hasEnrollmentSlotAvailable
|
||||||
|
)
|
||||||
|
|
||||||
function formatDate(d: string) {
|
function formatDate(d: string) {
|
||||||
const date = new Date(d)
|
const date = new Date(d)
|
||||||
@@ -24,6 +32,15 @@ function starsHtml(rating: number) {
|
|||||||
function goDetail() {
|
function goDetail() {
|
||||||
router.push(`/lecture/${props.lecture.id}`)
|
router.push(`/lecture/${props.lecture.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
if (isRegistrationLimitReached.value) {
|
||||||
|
enrollmentLimitModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('register', props.lecture.id)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -98,7 +115,7 @@ function goDetail() {
|
|||||||
<button
|
<button
|
||||||
v-else-if="lecture.freeSeats > 0 && !lecture.registrationClosed"
|
v-else-if="lecture.freeSeats > 0 && !lecture.registrationClosed"
|
||||||
class="btn-primary btn-sm"
|
class="btn-primary btn-sm"
|
||||||
@click.stop="emit('register', lecture.id)"
|
@click.stop="register"
|
||||||
>
|
>
|
||||||
Записаться
|
Записаться
|
||||||
</button>
|
</button>
|
||||||
@@ -106,6 +123,7 @@ function goDetail() {
|
|||||||
{{ lecture.registrationClosed ? 'Запись закрыта' : 'Мест нет' }}
|
{{ lecture.registrationClosed ? 'Запись закрыта' : 'Мест нет' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { lecturesApi, usersApi } from '@/api'
|
import { lecturesApi, usersApi } from '@/api'
|
||||||
import { mapApiLecture, mapApiReview } from '@/api/mappers'
|
import { mapApiLecture, mapApiReview } from '@/api/mappers'
|
||||||
import type { Lecture, Review } from '@/types'
|
import type { Lecture, Review } from '@/types'
|
||||||
|
import { useUserStore } from './user'
|
||||||
|
|
||||||
export const useLecturesStore = defineStore('lectures', () => {
|
export const useLecturesStore = defineStore('lectures', () => {
|
||||||
const lectures = ref<Lecture[]>([])
|
const lectures = ref<Lecture[]>([])
|
||||||
@@ -74,16 +75,23 @@ export const useLecturesStore = defineStore('lectures', () => {
|
|||||||
async function register(lectureId: string) {
|
async function register(lectureId: string) {
|
||||||
const lecture = lectures.value.find(item => item.id === lectureId)
|
const lecture = lectures.value.find(item => item.id === lectureId)
|
||||||
if (!lecture || lecture.freeSeats === 0 || lecture.registrationClosed || registered.value.includes(lectureId)) return
|
if (!lecture || lecture.freeSeats === 0 || lecture.registrationClosed || registered.value.includes(lectureId)) return
|
||||||
|
const userStore = useUserStore()
|
||||||
|
if (!userStore.hasEnrollmentSlotAvailable) {
|
||||||
|
throw new Error('Лимит записей достигнут. Отмените одну из записей или повысьте уровень.')
|
||||||
|
}
|
||||||
|
|
||||||
await lecturesApi.enroll(lectureId)
|
await lecturesApi.enroll(lectureId)
|
||||||
registered.value.push(lectureId)
|
registered.value.push(lectureId)
|
||||||
lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0)
|
lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0)
|
||||||
lecture.enrolledSeats += 1
|
lecture.enrolledSeats += 1
|
||||||
lecture.registered = true
|
lecture.registered = true
|
||||||
|
userStore.adjustActiveEnrollments(1)
|
||||||
|
await userStore.fetchStats().catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unregister(lectureId: string) {
|
async function unregister(lectureId: string) {
|
||||||
await lecturesApi.unenroll(lectureId)
|
await lecturesApi.unenroll(lectureId)
|
||||||
|
const userStore = useUserStore()
|
||||||
registered.value = registered.value.filter(id => id !== lectureId)
|
registered.value = registered.value.filter(id => id !== lectureId)
|
||||||
const lecture = lectures.value.find(item => item.id === lectureId)
|
const lecture = lectures.value.find(item => item.id === lectureId)
|
||||||
if (lecture) {
|
if (lecture) {
|
||||||
@@ -91,6 +99,8 @@ export const useLecturesStore = defineStore('lectures', () => {
|
|||||||
lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0)
|
lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0)
|
||||||
lecture.registered = false
|
lecture.registered = false
|
||||||
}
|
}
|
||||||
|
userStore.adjustActiveEnrollments(-1)
|
||||||
|
await userStore.fetchStats().catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRegistered(lectureId: string) {
|
function isRegistered(lectureId: string) {
|
||||||
|
|||||||
+55
-14
@@ -1,7 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { achievementsApi, notificationsApi, usersApi } from '@/api'
|
import { achievementsApi, notificationsApi, usersApi } from '@/api'
|
||||||
import { mapApiAchievement, mapApiCoinTransaction, mapApiNotification } from '@/api/mappers'
|
import { mapApiAchievement, mapApiCoinTransaction, mapApiNotification } from '@/api/mappers'
|
||||||
|
import type { UserStatsDto } from '@/api/types'
|
||||||
import type { Achievement, CoinTransaction, Notification } from '@/types'
|
import type { Achievement, CoinTransaction, Notification } from '@/types'
|
||||||
import { useAuthStore } from './auth'
|
import { useAuthStore } from './auth'
|
||||||
|
|
||||||
@@ -11,6 +12,53 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const coinHistory = ref<CoinTransaction[]>([])
|
const coinHistory = ref<CoinTransaction[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const activeEnrollments = computed(() => useAuthStore().user?.activeEnrollments ?? 0)
|
||||||
|
const enrollmentSlotLimit = computed(() => useAuthStore().user?.enrollmentSlotLimit ?? 0)
|
||||||
|
const hasEnrollmentSlotAvailable = computed(() =>
|
||||||
|
enrollmentSlotLimit.value === 0 || activeEnrollments.value < enrollmentSlotLimit.value
|
||||||
|
)
|
||||||
|
|
||||||
|
function applyStats(stats: UserStatsDto) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!auth.user) return
|
||||||
|
|
||||||
|
auth.setUser({
|
||||||
|
...auth.user,
|
||||||
|
coins: stats.coins,
|
||||||
|
level: stats.level,
|
||||||
|
xp: stats.xp,
|
||||||
|
currentLevelXp: stats.currentLevelXp,
|
||||||
|
nextLevelXp: stats.nextLevelXp,
|
||||||
|
lecturesAttended: stats.attendedLectures,
|
||||||
|
hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10,
|
||||||
|
achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)),
|
||||||
|
activeEnrollments: stats.activeEnrollments,
|
||||||
|
enrollmentSlotLimit: stats.enrollmentSlotLimit,
|
||||||
|
enrollmentSlotRules: stats.enrollmentSlotRules,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const stats = await usersApi.myStats()
|
||||||
|
applyStats(stats)
|
||||||
|
return stats
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Не удалось загрузить статистику профиля.'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustActiveEnrollments(delta: number) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!auth.user || typeof auth.user.activeEnrollments !== 'number') return
|
||||||
|
|
||||||
|
auth.setUser({
|
||||||
|
...auth.user,
|
||||||
|
activeEnrollments: Math.max(auth.user.activeEnrollments + delta, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchStudentData() {
|
async function fetchStudentData() {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -29,19 +77,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
notificationsApi.list(),
|
notificationsApi.list(),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (auth.user) {
|
applyStats(stats)
|
||||||
auth.setUser({
|
|
||||||
...auth.user,
|
|
||||||
coins: stats.coins,
|
|
||||||
level: stats.level,
|
|
||||||
xp: stats.xp,
|
|
||||||
currentLevelXp: stats.currentLevelXp,
|
|
||||||
nextLevelXp: stats.nextLevelXp,
|
|
||||||
lecturesAttended: stats.attendedLectures,
|
|
||||||
hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10,
|
|
||||||
achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const unlocked = new Map(achievementPayload.map(item => {
|
const unlocked = new Map(achievementPayload.map(item => {
|
||||||
const achievement = mapApiAchievement(item)
|
const achievement = mapApiAchievement(item)
|
||||||
return [achievement.id, achievement]
|
return [achievement.id, achievement]
|
||||||
@@ -85,6 +121,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
coinHistory,
|
coinHistory,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
activeEnrollments,
|
||||||
|
enrollmentSlotLimit,
|
||||||
|
hasEnrollmentSlotAvailable,
|
||||||
|
fetchStats,
|
||||||
|
adjustActiveEnrollments,
|
||||||
fetchStudentData,
|
fetchStudentData,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export interface User {
|
|||||||
lecturesAttended?: number
|
lecturesAttended?: number
|
||||||
hoursLearned?: number
|
hoursLearned?: number
|
||||||
achievements?: string[]
|
achievements?: string[]
|
||||||
|
activeEnrollments?: number
|
||||||
|
enrollmentSlotLimit?: number
|
||||||
|
enrollmentSlotRules?: EnrollmentSlotRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnrollmentSlotRule {
|
||||||
|
level: number
|
||||||
|
slots: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Lecture {
|
export interface Lecture {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import FilterChips from '@/components/ui/FilterChips.vue'
|
|||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
import DataTable from '@/components/ui/DataTable.vue'
|
import DataTable from '@/components/ui/DataTable.vue'
|
||||||
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
||||||
|
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
||||||
|
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -19,6 +20,7 @@ const building = ref('Все корпуса')
|
|||||||
const format = ref<'all' | 'online' | 'offline'>('all')
|
const format = ref<'all' | 'online' | 'offline'>('all')
|
||||||
const onlyFree = ref(false)
|
const onlyFree = ref(false)
|
||||||
const filtersOpen = ref(false)
|
const filtersOpen = ref(false)
|
||||||
|
const enrollmentLimitModalOpen = ref(false)
|
||||||
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -101,11 +103,19 @@ const calendarGroups = computed(() => {
|
|||||||
return Object.entries(groups)
|
return Object.entries(groups)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isEnrollmentLimitError(err: unknown) {
|
||||||
|
return err instanceof Error && err.message.includes('Лимит записей достигнут')
|
||||||
|
}
|
||||||
|
|
||||||
async function registerLecture(id: string) {
|
async function registerLecture(id: string) {
|
||||||
try {
|
try {
|
||||||
await lecturesStore.register(id)
|
await lecturesStore.register(id)
|
||||||
addToast?.('Вы записаны на лекцию.', 'success')
|
addToast?.('Вы записаны на лекцию.', 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isEnrollmentLimitError(err)) {
|
||||||
|
enrollmentLimitModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
|
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,6 +307,8 @@ function isRegistered(id: string) {
|
|||||||
<button class="btn-primary" @click="filtersOpen = false">Применить</button>
|
<button class="btn-primary" @click="filtersOpen = false">Применить</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
|
||||||
|
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
@@ -11,12 +11,15 @@ import ProgressBar from '@/components/ui/ProgressBar.vue'
|
|||||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue'
|
import AppIcon from '@/components/ui/AppIcon.vue'
|
||||||
|
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
||||||
import { formatUserName } from '@/utils/formatUserName'
|
import { formatUserName } from '@/utils/formatUserName'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const lectures = useLecturesStore()
|
const lectures = useLecturesStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const enrollmentLimitModalOpen = ref(false)
|
||||||
|
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
||||||
|
|
||||||
const user = computed(() => auth.user!)
|
const user = computed(() => auth.user!)
|
||||||
|
|
||||||
@@ -60,6 +63,19 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
await lectures.fetchRegisteredForCurrentUser()
|
await lectures.fetchRegisteredForCurrentUser()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function registerLecture(id: string) {
|
||||||
|
try {
|
||||||
|
await lectures.register(id)
|
||||||
|
addToast?.('Вы записаны на лекцию.', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes('Лимит записей достигнут')) {
|
||||||
|
enrollmentLimitModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -128,7 +144,7 @@ onMounted(async () => {
|
|||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<span class="title-with-icon">
|
<span class="title-with-icon">
|
||||||
<AppIcon class="title-icon" icon="sparkles" :size="18" />
|
<AppIcon class="title-icon" icon="sparkles" :size="18" />
|
||||||
Рекомендуемые лекции
|
Ближайшие лекции
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button class="link-btn" @click="router.push('/catalog')">Все лекции →</button>
|
<button class="link-btn" @click="router.push('/catalog')">Все лекции →</button>
|
||||||
@@ -140,7 +156,7 @@ onMounted(async () => {
|
|||||||
:key="l.id"
|
:key="l.id"
|
||||||
:lecture="l"
|
:lecture="l"
|
||||||
:registered="lectures.registeredIds.includes(l.id)"
|
:registered="lectures.registeredIds.includes(l.id)"
|
||||||
@register="lectures.register"
|
@register="registerLecture"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -182,6 +198,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import LectureCard from '@/components/ui/LectureCard.vue'
|
import LectureCard from '@/components/ui/LectureCard.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
||||||
|
const enrollmentLimitModalOpen = ref(false)
|
||||||
|
|
||||||
const lectureId = computed(() => String(route.params.id))
|
const lectureId = computed(() => String(route.params.id))
|
||||||
const lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.value))
|
const lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.value))
|
||||||
const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false))
|
const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false))
|
||||||
|
const slotRegistrationDisabled = computed(() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value)
|
||||||
const isAttended = computed(() => lecture.value?.status === 'completed')
|
const isAttended = computed(() => lecture.value?.status === 'completed')
|
||||||
const reviews = computed(() => lecturesStore.reviewsByLecture[lectureId.value] ?? [])
|
const reviews = computed(() => lecturesStore.reviewsByLecture[lectureId.value] ?? [])
|
||||||
|
|
||||||
@@ -24,6 +30,25 @@ onMounted(async () => {
|
|||||||
await lecturesStore.fetchLecture(lectureId.value)
|
await lecturesStore.fetchLecture(lectureId.value)
|
||||||
await lecturesStore.fetchReviews(lectureId.value)
|
await lecturesStore.fetchReviews(lectureId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function registerLecture() {
|
||||||
|
if (!lecture.value) return
|
||||||
|
if (slotRegistrationDisabled.value) {
|
||||||
|
enrollmentLimitModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await lecturesStore.register(lecture.value.id)
|
||||||
|
addToast?.('Вы записаны на лекцию.', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes('Лимит записей достигнут')) {
|
||||||
|
enrollmentLimitModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -49,7 +74,7 @@ onMounted(async () => {
|
|||||||
v-if="!isRegistered"
|
v-if="!isRegistered"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="lecture.freeSeats === 0 || lecture.registrationClosed"
|
:disabled="lecture.freeSeats === 0 || lecture.registrationClosed"
|
||||||
@click="lecturesStore.register(lecture.id)"
|
@click="registerLecture"
|
||||||
>
|
>
|
||||||
Записаться
|
Записаться
|
||||||
</button>
|
</button>
|
||||||
@@ -107,6 +132,8 @@ onMounted(async () => {
|
|||||||
<LectureCard v-for="l in similarLectures" :key="l.id" :lecture="l" />
|
<LectureCard v-for="l in similarLectures" :key="l.id" :lecture="l" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import GlassCard from '@/components/ui/GlassCard.vue'
|
|||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
import AppIcon from '@/components/ui/AppIcon.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -41,6 +42,20 @@ const levelProgressLabel = computed(() =>
|
|||||||
const levelProgressText = computed(() =>
|
const levelProgressText = computed(() =>
|
||||||
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
|
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
|
||||||
)
|
)
|
||||||
|
const activeEnrollments = computed(() => user.value.activeEnrollments ?? 0)
|
||||||
|
const enrollmentSlotLimit = computed(() => user.value.enrollmentSlotLimit ?? 0)
|
||||||
|
const enrollmentSlotsRemaining = computed(() =>
|
||||||
|
Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0)
|
||||||
|
)
|
||||||
|
const enrollmentSlotRules = computed(() => user.value.enrollmentSlotRules ?? [])
|
||||||
|
const enrollmentSlotText = computed(() =>
|
||||||
|
enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...'
|
||||||
|
)
|
||||||
|
const enrollmentSlotHint = computed(() => {
|
||||||
|
if (!enrollmentSlotLimit.value) return 'Загружаем лимит активных записей.'
|
||||||
|
if (enrollmentSlotsRemaining.value === 0) return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.'
|
||||||
|
return `Можно записаться еще на ${formatLectureCount(enrollmentSlotsRemaining.value)}.`
|
||||||
|
})
|
||||||
const unlockedAchievements = computed(() => userStore.achievements.filter(a => a.unlocked))
|
const unlockedAchievements = computed(() => userStore.achievements.filter(a => a.unlocked))
|
||||||
const lockedAchievements = computed(() => userStore.achievements.filter(a => !a.unlocked))
|
const lockedAchievements = computed(() => userStore.achievements.filter(a => !a.unlocked))
|
||||||
const interestTags = ref([
|
const interestTags = ref([
|
||||||
@@ -54,6 +69,22 @@ const interestTags = ref([
|
|||||||
|
|
||||||
const notificationSettings = ref({ email: true })
|
const notificationSettings = ref({ email: true })
|
||||||
|
|
||||||
|
function formatSlotCount(slots: number) {
|
||||||
|
const lastDigit = slots % 10
|
||||||
|
const lastTwoDigits = slots % 100
|
||||||
|
if (lastDigit === 1 && lastTwoDigits !== 11) return `${slots} слот`
|
||||||
|
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${slots} слота`
|
||||||
|
return `${slots} слотов`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLectureCount(count: number) {
|
||||||
|
const lastDigit = count % 10
|
||||||
|
const lastTwoDigits = count % 100
|
||||||
|
if (lastDigit === 1 && lastTwoDigits !== 11) return `${count} лекцию`
|
||||||
|
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${count} лекции`
|
||||||
|
return `${count} лекций`
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void userStore.fetchStudentData()
|
void userStore.fetchStudentData()
|
||||||
})
|
})
|
||||||
@@ -99,6 +130,28 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard>
|
||||||
|
<div class="section-title">Слоты записи</div>
|
||||||
|
<div class="slot-overview">
|
||||||
|
<div class="slot-meter">
|
||||||
|
<AppIcon icon="calendar" :size="22" />
|
||||||
|
<span>{{ enrollmentSlotText }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="slot-hint">{{ enrollmentSlotHint }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="slot-rules" v-if="enrollmentSlotRules.length">
|
||||||
|
<div
|
||||||
|
v-for="rule in enrollmentSlotRules"
|
||||||
|
:key="rule.level"
|
||||||
|
class="slot-rule"
|
||||||
|
:class="{ active: user.level >= rule.level }"
|
||||||
|
>
|
||||||
|
<span>Ур. {{ rule.level }}</span>
|
||||||
|
<strong>{{ formatSlotCount(rule.slots) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Настройки уведомлений</div>
|
<div class="section-title">Настройки уведомлений</div>
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
@@ -167,8 +220,71 @@ onMounted(() => {
|
|||||||
.level { margin: 16px 0; }
|
.level { margin: 16px 0; }
|
||||||
.level-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
|
.level-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
|
||||||
.tags-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
.tags-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
|
.slot-overview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.slot-meter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 106px;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.slot-meter :deep(.app-icon) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.slot-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.slot-rules {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.slot-rule {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-slate-500-a20);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-white-a50);
|
||||||
|
}
|
||||||
|
.slot-rule span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.slot-rule strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.slot-rule.active {
|
||||||
|
border-color: var(--color-primary-a30);
|
||||||
|
background: var(--color-primary-a08);
|
||||||
|
}
|
||||||
.settings { display: flex; flex-direction: column; gap: 8px; }
|
.settings { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; }
|
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; }
|
||||||
.achievements-section { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; }
|
.achievements-section { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; }
|
||||||
.achievements-list { display: flex; flex-direction: column; gap: 12px; }
|
.achievements-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.slot-overview {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.slot-hint {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user