feat: добавил ограничение записи на лекции
Backend CI / build-and-test (push) Failing after 32s
Frontend CI / build-and-check (push) Failing after 5m5s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 1m28s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped

This commit is contained in:
2026-05-21 19:34:08 +03:00
parent 32b8bdfd24
commit 2e7ce6c2e8
21 changed files with 569 additions and 23 deletions
+2
View File
@@ -68,6 +68,8 @@ export async function apiRequest<T>(
const message =
typeof body === 'object' && body && 'message' in body
? 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}`
throw new ApiError(message, response.status, body)
}
+3
View File
@@ -50,6 +50,9 @@ export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?:
lecturesAttended: stats?.attendedLectures ?? 0,
hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0,
achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [],
activeEnrollments: stats?.activeEnrollments,
enrollmentSlotLimit: stats?.enrollmentSlotLimit,
enrollmentSlotRules: stats?.enrollmentSlotRules,
}
}
+8
View File
@@ -70,6 +70,14 @@ export interface UserStatsDto {
achievementsCount: number
currentLevelXp: number
nextLevelXp?: number | null
activeEnrollments: number
enrollmentSlotLimit: number
enrollmentSlotRules: EnrollmentSlotRuleDto[]
}
export interface EnrollmentSlotRuleDto {
level: number
slots: number
}
export interface LectureDto {
@@ -17,6 +17,11 @@ const isCoinDialogOpen = ref(false)
const profileMenuRef = ref<HTMLElement | null>(null)
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> = {
student: 'Студент',
teacher: 'Преподаватель',
@@ -85,6 +90,7 @@ function handleDocumentPointerDown(event: PointerEvent) {
onMounted(() => {
document.addEventListener('pointerdown', handleDocumentPointerDown)
if (auth.user?.roles.includes('student')) void userStore.fetchStats().catch(() => undefined)
})
onBeforeUnmount(() => {
@@ -115,6 +121,11 @@ onBeforeUnmount(() => {
<span class="level-value">{{ auth.user.level }}</span>
</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')">
<AppIcon icon="bell" :size="18" />
<span class="notif-dot" v-if="auth.user && unreadCount > 0">
@@ -277,6 +288,26 @@ onBeforeUnmount(() => {
font-size: 14px;
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 {
position: relative;
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>
+19 -1
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import type { Lecture } from '@/types'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/ui/AppIcon.vue'
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
const props = defineProps<{
lecture: Lecture
@@ -10,6 +13,11 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ register: [id: string] }>()
const router = useRouter()
const userStore = useUserStore()
const enrollmentLimitModalOpen = ref(false)
const isRegistrationLimitReached = computed(() =>
!props.registered && !userStore.hasEnrollmentSlotAvailable
)
function formatDate(d: string) {
const date = new Date(d)
@@ -24,6 +32,15 @@ function starsHtml(rating: number) {
function goDetail() {
router.push(`/lecture/${props.lecture.id}`)
}
function register() {
if (isRegistrationLimitReached.value) {
enrollmentLimitModalOpen.value = true
return
}
emit('register', props.lecture.id)
}
</script>
<template>
@@ -98,7 +115,7 @@ function goDetail() {
<button
v-else-if="lecture.freeSeats > 0 && !lecture.registrationClosed"
class="btn-primary btn-sm"
@click.stop="emit('register', lecture.id)"
@click.stop="register"
>
Записаться
</button>
@@ -106,6 +123,7 @@ function goDetail() {
{{ lecture.registrationClosed ? 'Запись закрыта' : 'Мест нет' }}
</button>
</div>
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
</div>
</template>
+10
View File
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import { lecturesApi, usersApi } from '@/api'
import { mapApiLecture, mapApiReview } from '@/api/mappers'
import type { Lecture, Review } from '@/types'
import { useUserStore } from './user'
export const useLecturesStore = defineStore('lectures', () => {
const lectures = ref<Lecture[]>([])
@@ -74,16 +75,23 @@ export const useLecturesStore = defineStore('lectures', () => {
async function register(lectureId: string) {
const lecture = lectures.value.find(item => item.id === lectureId)
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)
registered.value.push(lectureId)
lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0)
lecture.enrolledSeats += 1
lecture.registered = true
userStore.adjustActiveEnrollments(1)
await userStore.fetchStats().catch(() => undefined)
}
async function unregister(lectureId: string) {
await lecturesApi.unenroll(lectureId)
const userStore = useUserStore()
registered.value = registered.value.filter(id => id !== lectureId)
const lecture = lectures.value.find(item => item.id === lectureId)
if (lecture) {
@@ -91,6 +99,8 @@ export const useLecturesStore = defineStore('lectures', () => {
lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0)
lecture.registered = false
}
userStore.adjustActiveEnrollments(-1)
await userStore.fetchStats().catch(() => undefined)
}
function isRegistered(lectureId: string) {
+55 -14
View File
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { achievementsApi, notificationsApi, usersApi } from '@/api'
import { mapApiAchievement, mapApiCoinTransaction, mapApiNotification } from '@/api/mappers'
import type { UserStatsDto } from '@/api/types'
import type { Achievement, CoinTransaction, Notification } from '@/types'
import { useAuthStore } from './auth'
@@ -11,6 +12,53 @@ export const useUserStore = defineStore('user', () => {
const coinHistory = ref<CoinTransaction[]>([])
const loading = ref(false)
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() {
const auth = useAuthStore()
@@ -29,19 +77,7 @@ export const useUserStore = defineStore('user', () => {
notificationsApi.list(),
])
if (auth.user) {
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)),
})
}
applyStats(stats)
const unlocked = new Map(achievementPayload.map(item => {
const achievement = mapApiAchievement(item)
return [achievement.id, achievement]
@@ -85,6 +121,11 @@ export const useUserStore = defineStore('user', () => {
coinHistory,
loading,
error,
activeEnrollments,
enrollmentSlotLimit,
hasEnrollmentSlotAvailable,
fetchStats,
adjustActiveEnrollments,
fetchStudentData,
fetchNotifications,
markAllRead,
+8
View File
@@ -18,6 +18,14 @@ export interface User {
lecturesAttended?: number
hoursLearned?: number
achievements?: string[]
activeEnrollments?: number
enrollmentSlotLimit?: number
enrollmentSlotRules?: EnrollmentSlotRule[]
}
export interface EnrollmentSlotRule {
level: number
slots: number
}
export interface Lecture {
@@ -8,6 +8,7 @@ import FilterChips from '@/components/ui/FilterChips.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import DataTable from '@/components/ui/DataTable.vue'
import ModalDialog from '@/components/ui/ModalDialog.vue'
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
const lecturesStore = useLecturesStore()
const search = ref('')
@@ -19,6 +20,7 @@ const building = ref('Все корпуса')
const format = ref<'all' | 'online' | 'offline'>('all')
const onlyFree = ref(false)
const filtersOpen = ref(false)
const enrollmentLimitModalOpen = ref(false)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
onMounted(() => {
@@ -101,11 +103,19 @@ const calendarGroups = computed(() => {
return Object.entries(groups)
})
function isEnrollmentLimitError(err: unknown) {
return err instanceof Error && err.message.includes('Лимит записей достигнут')
}
async function registerLecture(id: string) {
try {
await lecturesStore.register(id)
addToast?.('Вы записаны на лекцию.', 'success')
} catch (err) {
if (isEnrollmentLimitError(err)) {
enrollmentLimitModalOpen.value = true
return
}
addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error')
}
}
@@ -297,6 +307,8 @@ function isRegistered(id: string) {
<button class="btn-primary" @click="filtersOpen = false">Применить</button>
</template>
</ModalDialog>
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
</div>
</template>
+21 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, inject, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useLecturesStore } from '@/stores/lectures'
@@ -11,12 +11,15 @@ import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import AppIcon from '@/components/ui/AppIcon.vue'
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
import { formatUserName } from '@/utils/formatUserName'
const auth = useAuthStore()
const lectures = useLecturesStore()
const userStore = useUserStore()
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!)
@@ -60,6 +63,19 @@ onMounted(async () => {
])
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>
<template>
@@ -128,7 +144,7 @@ onMounted(async () => {
<h2 class="section-title">
<span class="title-with-icon">
<AppIcon class="title-icon" icon="sparkles" :size="18" />
Рекомендуемые лекции
Ближайшие лекции
</span>
</h2>
<button class="link-btn" @click="router.push('/catalog')">Все лекции </button>
@@ -140,7 +156,7 @@ onMounted(async () => {
:key="l.id"
:lecture="l"
:registered="lectures.registeredIds.includes(l.id)"
@register="lectures.register"
@register="registerLecture"
/>
</div>
</section>
@@ -182,6 +198,8 @@ onMounted(async () => {
</div>
</GlassCard>
</section>
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
</div>
</template>
@@ -1,19 +1,25 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, inject, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
import LectureCard from '@/components/ui/LectureCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
const route = useRoute()
const router = useRouter()
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 lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.value))
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 reviews = computed(() => lecturesStore.reviewsByLecture[lectureId.value] ?? [])
@@ -24,6 +30,25 @@ onMounted(async () => {
await lecturesStore.fetchLecture(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>
<template>
@@ -49,7 +74,7 @@ onMounted(async () => {
v-if="!isRegistered"
class="btn-primary"
:disabled="lecture.freeSeats === 0 || lecture.registrationClosed"
@click="lecturesStore.register(lecture.id)"
@click="registerLecture"
>
Записаться
</button>
@@ -107,6 +132,8 @@ onMounted(async () => {
<LectureCard v-for="l in similarLectures" :key="l.id" :lecture="l" />
</div>
</section>
<EnrollmentLimitModal v-model="enrollmentLimitModalOpen" />
</div>
</template>
+116
View File
@@ -6,6 +6,7 @@ import GlassCard from '@/components/ui/GlassCard.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import AppIcon from '@/components/ui/AppIcon.vue'
const auth = useAuthStore()
const userStore = useUserStore()
@@ -41,6 +42,20 @@ const levelProgressLabel = computed(() =>
const levelProgressText = computed(() =>
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 lockedAchievements = computed(() => userStore.achievements.filter(a => !a.unlocked))
const interestTags = ref([
@@ -54,6 +69,22 @@ const interestTags = ref([
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(() => {
void userStore.fetchStudentData()
})
@@ -99,6 +130,28 @@ onMounted(() => {
</div>
</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>
<div class="section-title">Настройки уведомлений</div>
<div class="settings">
@@ -167,8 +220,71 @@ onMounted(() => {
.level { margin: 16px 0; }
.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; }
.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; }
.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-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>