moove bbvue

This commit is contained in:
2025-10-16 01:57:19 +05:00
parent 4b04034f18
commit 6ab25eb073
83 changed files with 17809 additions and 1 deletions
+224
View File
@@ -0,0 +1,224 @@
<template>
<div id="app">
<header class="app-header">
<div class="container header-container">
<!-- Логотип -->
<router-link to="/" class="logo-link">
<div class="logo">
<div class="logo-box">
<img src="./assets/logo/Logo.png" alt="Little logo begushiy bashkir" class="little-logo">
</div>
<div class="logo-box">
<span><i>Бегущий Башкир</i></span>
</div>
<div class="logo-box team">
<span>team</span>
</div>
</div>
</router-link>
<!-- Используем компонент меню -->
<NavigationMenu />
</div>
</header>
<main class="main-content">
<router-view />
</main>
<footer class="app-footer">
<div class="container">
<p>© 2025 Беговой клуб "Бегущий Башкир". Все права защищены.</p>
<p>Уфа, Республика Башкортостан</p>
</div>
</footer>
</div>
</template>
<script>
import NavigationMenu from './components/NavigationMenu.vue'
export default {
name: 'App',
components: {
NavigationMenu
}
}
</script>
<style>
/* Остальные стили остаются без изменений */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
a {
text-decoration: none;
color: inherit;
}
body {
font-family: 'Arial', sans-serif;
line-height: 1.6;
color: #333;
overflow-x: hidden;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Header Styles */
.app-header {
background-color: #2e8b56;
color: white;
padding: 1rem 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
font-size: 2em;
}
/* Logo Styles */
.logo-link {
text-decoration: none;
color: inherit;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
z-index: 1002;
}
.logo-box {
display: flex;
align-items: center;
}
.little-logo {
width: 2.5rem;
height: 2.5rem;
object-fit: contain;
}
.team {
font-size: 0.8rem;
opacity: 0.8;
align-self: flex-end;
margin-bottom: 0.2rem;
}
/* Main Content */
.main-content {
min-height: calc(100vh - 140px);
padding: 2rem 0;
}
/* Footer */
.app-footer {
background-color: #1a3e23;
color: white;
text-align: center;
padding: 1.5rem 0;
margin-top: 2rem;
}
.app-footer p {
margin-bottom: 0.5rem;
}
/* Responsive Design */
@media (max-width: 1023px) and (min-width: 768px) {
.team {
display: none;
}
}
@media (max-width: 767px) {
.logo {
font-size: 1.1rem;
}
.team {
display: none;
}
.little-logo {
width: 2rem;
height: 2rem;
}
.container {
padding: 0 15px;
}
}
@media (max-width: 360px) {
.logo-box:nth-child(2) {
display: none;
}
}
/* Button Styles */
.btn {
display: inline-block;
background-color: #ffd700;
color: #333;
padding: 12px 30px;
border-radius: 5px;
text-decoration: none;
font-weight: bold;
border: none;
cursor: pointer;
transition: background-color 0.3s;
margin: 0.5rem;
}
.btn:hover {
background-color: #e6c200;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
/* Page Styles */
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.page h1 {
color: #2e8b57;
margin-bottom: 1rem;
font-size: 2.5rem;
}
.page p {
font-size: 1.1rem;
margin-bottom: 1.5rem;
color: #666;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
@font-face {
font-family: 'LogoFont';
src: url('./fonts/Lobster-Regular.ttf');
font-weight: normal;
font-style: normal;
}
html, body {
margin: 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+11
View File
@@ -0,0 +1,11 @@
@import './base.css';
html {
margin: 0;
padding: 0;
}
body {
margin: 0;
padding: 0;
}
@@ -0,0 +1,234 @@
<!-- AvatarUpload.vue -->
<template>
<div class="avatar-upload">
<div class="avatar-preview">
<img v-if="previewUrl" :src="previewUrl" alt="Аватар" class="avatar-image" @error="handleImageError" />
<div v-else class="avatar-placeholder">
👤
</div>
</div>
<div v-if="showActions" class="avatar-actions">
<label class="btn btn-small" :class="{ 'btn-disabled': uploading }">
{{ uploading ? 'Загрузка...' : '📷 Загрузить' }}
<input type="file" accept="image/*" @change="handleFileSelect" :disabled="uploading"
style="display: none;">
</label>
<button v-if="previewUrl" class="btn btn-small btn-danger" @click="deleteAvatar" :disabled="uploading">
🗑 Удалить
</button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</template>
<script>
import { useAuthStore } from '../stores/auth'
export default {
name: 'AvatarUpload',
props: {
user: Object,
showActions: {
type: Boolean,
default: true
}
},
setup() {
const authStore = useAuthStore()
return { authStore }
},
data() {
return {
previewUrl: null,
uploading: false,
error: ''
}
},
watch: {
user: {
immediate: true,
handler(newUser) {
console.log('User data in AvatarUpload:', newUser)
if (newUser?.avatar) {
console.log('Avatar path:', newUser.avatar)
const fullUrl = this.getFullAvatarUrl(newUser.avatar)
console.log('Full avatar URL:', fullUrl)
this.previewUrl = fullUrl
} else {
console.log('No avatar found')
this.previewUrl = null
}
}
}
},
methods: {
getFullAvatarUrl(avatarPath) {
if (!avatarPath) return null;
console.log('Building URL for avatar path:', avatarPath);
if (avatarPath.startsWith('http')) {
return avatarPath;
}
// Извлекаем имя файла из пути
const filename = avatarPath.trim('/').split('/').pop();
// Используем API эндпоинт вместо прямого доступа
const fullUrl = `https://begushiybashkir.ru/api/v1/user/avatars/${filename}`;
console.log('Built URL with API endpoint:', fullUrl);
return fullUrl;
},
handleImageError(event) {
console.error('Error loading avatar image:', event)
this.previewUrl = null
},
handleFileSelect(event) {
const file = event.target.files[0]
if (file) {
// Валидация файла
if (!file.type.startsWith('image/')) {
this.error = 'Пожалуйста, выберите файл изображения'
return
}
if (file.size > 5 * 1024 * 1024) { // 5MB
this.error = 'Размер файла не должен превышать 5MB'
return
}
// Создаем preview
const reader = new FileReader()
reader.onload = (e) => {
this.previewUrl = e.target.result
}
reader.readAsDataURL(file)
// Загружаем на сервер
this.uploadAvatar(file)
// Сбрасываем input
event.target.value = ''
}
},
async uploadAvatar(file) {
this.uploading = true
this.error = ''
try {
const result = await this.authStore.updateAvatar(file)
console.log('Upload result:', result)
if (result.success) {
this.$emit('avatar-updated', result.avatar)
} else {
this.error = result.error || 'Ошибка загрузки'
// Восстанавливаем старый preview
this.previewUrl = this.getFullAvatarUrl(this.user?.avatar)
}
} catch (err) {
console.error('Upload error:', err)
this.error = 'Ошибка загрузки: ' + (err.message || 'Неизвестная ошибка')
this.previewUrl = this.getFullAvatarUrl(this.user?.avatar)
} finally {
this.uploading = false
}
},
async deleteAvatar() {
this.uploading = true
this.error = ''
try {
const result = await this.authStore.deleteAvatar()
console.log('Delete result:', result)
if (result.success) {
this.previewUrl = null
this.$emit('avatar-updated', null)
} else {
this.error = result.error || 'Ошибка удаления'
}
} catch (err) {
console.error('Delete error:', err)
this.error = 'Ошибка удаления: ' + (err.message || 'Неизвестная ошибка')
} finally {
this.uploading = false
}
}
}
}
</script>
<style scoped>
.avatar-upload {
text-align: center;
margin: 1rem 0;
}
.avatar-preview {
width: 120px;
height: 120px;
margin: 0 auto 1rem;
border-radius: 50%;
overflow: hidden;
border: 3px solid #e0e0e0;
background-color: #f5f5f5;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background-color: #e0e0e0;
}
.avatar-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #c82333;
}
.btn-disabled {
background-color: #ccc;
cursor: not-allowed;
}
.error-message {
color: #dc3545;
font-size: 0.9rem;
margin-top: 0.5rem;
}
</style>
@@ -0,0 +1,418 @@
<template>
<div>
<!-- Бургер-меню для всех устройств -->
<div class="burger-menu-container">
<button
class="burger-menu"
:class="{ 'active': isMobileMenuOpen }"
@click="toggleMobileMenu"
:aria-label="isMobileMenuOpen ? 'Закрыть меню' : 'Открыть меню'"
:aria-expanded="isMobileMenuOpen"
>
<span></span>
<span></span>
<span></span>
</button>
<!-- Выпадающее меню -->
<div class="dropdown-menu" :class="{ 'active': isMobileMenuOpen }" ref="dropdownMenu">
<nav class="dropdown-nav">
<div class="mobile-menu-header">
<div class="mobile-logo">
<img src="../assets/logo/Logo.png" alt="Little logo begushiy bashkir" class="little-logo">
<span>Бегущий Башкир</span>
</div>
<button class="close-menu" @click="closeMobileMenu" aria-label="Закрыть меню">
×
</button>
</div>
<div class="dropdown-nav-content">
<router-link to="/" class="dropdown-nav-link" @click="closeMobileMenu">
🏠 Главная
</router-link>
<router-link to="/profile" class="dropdown-nav-link" @click="closeMobileMenu">
👤 Профиль
</router-link>
<router-link to="/about" class="dropdown-nav-link" @click="closeMobileMenu">
👥 О нас
</router-link>
<router-link to="/achievements" class="dropdown-nav-link" @click="closeMobileMenu">
🏆 Достижения
</router-link>
<router-link to="/gallery" class="dropdown-nav-link" @click="closeMobileMenu">
📸 Галерея
</router-link>
<router-link to="/training" class="dropdown-nav-link" @click="closeMobileMenu">
📅 Тренировки
</router-link>
<router-link to="/news" class="dropdown-nav-link" @click="closeMobileMenu">
📰 Новости
</router-link>
<router-link to="/members" class="dropdown-nav-link" @click="closeMobileMenu">
👥 Участники
</router-link>
<router-link to="/reviews" class="dropdown-nav-link" @click="closeMobileMenu">
Отзывы
</router-link>
<div class="dropdown-divider"></div>
<router-link to="/register" class="dropdown-nav-link accent" @click="closeMobileMenu">
📝 Регистрация
</router-link>
<router-link to="/login" class="dropdown-nav-link accent" @click="closeMobileMenu">
🔐 Войти
</router-link>
</div>
</nav>
</div>
</div>
<!-- Оверлей для мобильных -->
<div class="mobile-overlay" :class="{ 'active': isMobileMenuOpen }" @click="closeMobileMenu"></div>
</div>
</template>
<script>
export default {
name: 'NavigationMenu',
data() {
return {
isMobileMenuOpen: false,
resizeTimeout: null
}
},
methods: {
toggleMobileMenu() {
this.isMobileMenuOpen = !this.isMobileMenuOpen
// Блокируем скролл body когда меню открыто на мобильных
if (window.innerWidth <= 767) {
document.body.style.overflow = this.isMobileMenuOpen ? 'hidden' : ''
}
},
closeMobileMenu() {
this.isMobileMenuOpen = false
document.body.style.overflow = ''
},
handleClickOutside(event) {
if (!event.target.closest('.burger-menu-container') &&
!event.target.closest('.dropdown-menu') &&
this.isMobileMenuOpen) {
this.closeMobileMenu()
}
},
handleEscapeKey(event) {
if (event.key === 'Escape' && this.isMobileMenuOpen) {
this.closeMobileMenu()
}
},
handleResize() {
// Дебаунс для оптимизации
clearTimeout(this.resizeTimeout)
this.resizeTimeout = setTimeout(() => {
// Закрываем мобильное меню при переходе на десктоп
if (window.innerWidth > 767 && this.isMobileMenuOpen) {
this.closeMobileMenu()
}
}, 250)
},
handleTouchMove(event) {
// Обработка свайпа для закрытия меню на мобильных
if (this.isMobileMenuOpen && window.innerWidth <= 767) {
const touch = event.touches[0]
const startX = touch.clientX
const menu = this.$refs.dropdownMenu
// Более точная проверка для свайпа
if (menu && startX < 50) { // Свайп от левого края
event.preventDefault()
this.closeMobileMenu()
}
}
}
},
mounted() {
document.addEventListener('click', this.handleClickOutside)
document.addEventListener('keydown', this.handleEscapeKey)
document.addEventListener('touchmove', this.handleTouchMove, { passive: false })
window.addEventListener('resize', this.handleResize)
// Закрываем меню при навигации
this.$router.afterEach(() => {
this.closeMobileMenu()
})
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside)
document.removeEventListener('keydown', this.handleEscapeKey)
document.removeEventListener('touchmove', this.handleTouchMove)
window.removeEventListener('resize', this.handleResize)
document.body.style.overflow = '' // Восстанавливаем скролл при размонтировании
}
}
</script>
<style scoped>
/* Стили остаются такими же как в App.vue */
.burger-menu-container {
position: relative;
z-index: 1001;
}
.burger-menu {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 30px;
height: 21px;
background: none;
border: none;
cursor: pointer;
padding: 0;
position: relative;
transition: transform 0.3s ease;
}
.burger-menu:hover {
transform: scale(1.1);
}
.burger-menu:active {
transform: scale(0.95);
}
.burger-menu span {
display: block;
height: 3px;
width: 100%;
background-color: white;
border-radius: 3px;
transition: all 0.3s ease;
transform-origin: center;
}
.burger-menu.active span:nth-child(1) {
transform: rotate(45deg) translate(6px, 6px);
}
.burger-menu.active span:nth-child(2) {
opacity: 0;
}
.burger-menu.active span:nth-child(3) {
transform: rotate(-45deg) translate(6px, -6px);
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
min-width: 280px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.3s ease;
border: 1px solid #e9ecef;
margin-top: 10px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.dropdown-menu.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.dropdown-nav {
display: flex;
flex-direction: column;
height: 100%;
}
.mobile-menu-header {
display: none;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: #2e8b57;
color: white;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.mobile-logo {
display: flex;
align-items: center;
gap: 0.8rem;
font-weight: bold;
}
.mobile-logo .little-logo {
width: 2rem;
height: 2rem;
}
.close-menu {
display: none;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.close-menu:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.dropdown-nav-content {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.dropdown-nav-link {
display: flex;
align-items: center;
padding: 1rem 1.5rem;
color: #333;
text-decoration: none;
transition: all 0.3s ease;
font-size: 1rem;
border-left: 3px solid transparent;
white-space: nowrap;
}
.dropdown-nav-link:hover {
background-color: #f8fff8;
color: #2e8b57;
border-left-color: #2e8b57;
padding-left: 2rem;
}
.dropdown-nav-link:active {
background-color: #e8f5e8;
transform: translateX(5px);
}
.dropdown-nav-link.router-link-active {
background-color: #f0f8f0;
color: #2e8b57;
border-left-color: #2e8b57;
font-weight: 600;
}
.dropdown-nav-link.accent {
background-color: #f8fff8;
color: #2e8b57;
font-weight: 600;
}
.dropdown-nav-link.accent:hover {
background-color: #2e8b57;
color: white;
}
.dropdown-divider {
height: 1px;
background: #e9ecef;
margin: 0.5rem 1.5rem;
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 998;
}
.mobile-overlay.active {
opacity: 1;
visibility: visible;
}
/* Адаптивность */
@media (max-width: 767px) {
.dropdown-menu {
position: fixed;
top: 0;
right: -100%;
width: 85%;
max-width: 320px;
height: 100vh;
border-radius: 0;
margin-top: 0;
transition: right 0.3s ease;
box-shadow: -5px 0 25px rgba(0, 0, 0, 0.3);
max-height: 100vh;
overflow: hidden;
}
.dropdown-menu.active {
right: 0;
}
.mobile-menu-header {
display: flex;
flex-shrink: 0;
}
.close-menu {
display: flex;
}
.dropdown-nav-content {
padding: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.dropdown-nav-link {
padding: 1.4rem 1.5rem;
font-size: 1.1rem;
border-left: none;
border-bottom: 1px solid #f0f0f0;
}
.dropdown-nav-link:hover,
.dropdown-nav-link.router-link-active {
border-left: none;
border-bottom: 1px solid #2e8b57;
padding-left: 1.5rem;
}
}
/* Стили для скроллбара */
.dropdown-nav-content::-webkit-scrollbar {
width: 4px;
}
.dropdown-nav-content::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dropdown-nav-content::-webkit-scrollbar-thumb {
background: #2e8b57;
border-radius: 2px;
}
.dropdown-nav-content::-webkit-scrollbar-thumb:hover {
background: #26734a;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
import './assets/main.css'
import { createApp } from 'vue'
import pinia from './stores'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(pinia)
app.use(router)
// Инициализация auth store после создания app
import { useAuthStore } from './stores/auth'
const authStore = useAuthStore()
// Инициализируем авторизацию
authStore.initializeAuth().then(() => {
console.log('Auth initialization completed')
}).catch(error => {
console.error('Auth initialization failed:', error)
})
app.mount('#app')
+160
View File
@@ -0,0 +1,160 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: () => import('../views/About.vue')
},
{
path: '/achievements',
name: 'Achievements',
component: () => import('../views/Achievements.vue')
},
{
path: '/gallery',
name: 'Gallery',
component: () => import('../views/Gallery.vue')
},
{
path: '/training',
name: 'Training',
component: () => import('../views/Training.vue')
},
{
path: '/news',
name: 'News',
component: () => import('../views/News.vue')
},
{
path: '/members',
name: 'Members',
component: () => import('../views/Members.vue')
},
{
path: '/reviews',
name: 'Reviews',
component: () => import('../views/Reviews.vue')
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { guestOnly: true }
},
{
path: '/profile',
name: 'Profile',
component: () => import('../views/Profile.vue'),
meta: { requiresAuth: true }
},
{
path: '/register',
name: 'Register',
component: () => import('../views/Register.vue'),
meta: { guestOnly: true }
},
{
path: '/profile/edit',
name: 'ProfileEdit',
component: () => import('../views/ProfileEdit.vue'),
meta: { requiresAuth: true }
},
{
path: '/terms',
name: 'TermsOfService',
component: () => import('../views/TermsOfService.vue')
},
{
path: '/privacy',
name: 'PrivacyPolicy',
component: () => import('../views/PrivacyPolicy.vue')
},
// Добавляем маршрут для выхода
{
path: '/logout',
name: 'Logout',
component: () => import('../views/Logout.vue')
}
]
})
// Функция для показа уведомлений
function showNotification(message) {
// Создаем элемент уведомления
const notification = document.createElement('div')
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #2e8b57;
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
max-width: 300px;
font-family: Arial, sans-serif;
`
notification.textContent = message
document.body.appendChild(notification)
// Автоматически удаляем через 3 секунды
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 3000)
}
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// Проверяем, требует ли маршрут аутентификации
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// Если есть токен, пробуем загрузить профиль
if (authStore.token) {
try {
await authStore.fetchProfile()
next()
return
} catch (error) {
console.log('Token validation failed:', error)
// Если токен невалидный, очищаем его и редиректим на логин
authStore.clearAuth()
next('/login')
return
}
} else {
// Если нет токена, редиректим на логин
next('/login')
return
}
}
// Проверяем, предназначен ли маршрут только для гостей
if (to.meta.guestOnly && authStore.isAuthenticated) {
showNotification("Вы уже авторизованы. Перенаправляем в профиль...")
// Ждем немного чтобы пользователь увидел уведомление, затем редиректим
setTimeout(() => {
next('/profile')
}, 2000)
return
}
// Если все проверки пройдены, разрешаем навигацию
next()
})
export default router
+209
View File
@@ -0,0 +1,209 @@
// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient, withLoading } from './helpers/api'
import { handleApiError } from './helpers/api';
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref(null)
const token = ref(localStorage.getItem('auth_token') || '')
const loading = ref(false)
const error = ref('')
const initialized = ref(false)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userFullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
// Actions
const setToken = (newToken) => {
token.value = newToken
localStorage.setItem('auth_token', newToken)
}
const clearAuth = () => {
token.value = ''
user.value = null
localStorage.removeItem('auth_token')
}
const setUser = (userData) => {
user.value = userData
}
const register = async (userData) => {
// Передаем store объект с loading и error
return withLoading({ loading, error }, async () => {
await apiClient.post('/auth/register', userData)
// Auto-login after registration
const loginResponse = await apiClient.post('/auth/login', {
email: userData.email,
password: userData.password
})
const { token: authToken, user: userInfo } = loginResponse.data
setToken(authToken)
setUser(userInfo)
return { success: true }
})
}
const login = async (credentials) => {
return withLoading({ loading, error }, async () => {
const response = await apiClient.post('/auth/login', credentials)
const { token: authToken, user: userInfo } = response.data
setToken(authToken)
setUser(userInfo)
return { success: true, data: response.data }
})
}
const logout = async () => {
return withLoading({ loading, error }, async () => {
try {
await apiClient.post('/auth/logout')
} catch (err) {
console.error('Logout error:', err)
} finally {
clearAuth()
}
return { success: true }
})
}
const fetchProfile = async () => {
return withLoading({ loading, error }, async () => {
const response = await apiClient.get('/user/profile')
setUser(response.data)
return { success: true, data: response.data }
})
}
const updateProfile = async (profileData) => {
return withLoading({ loading, error }, async () => {
const response = await apiClient.post('/user/editProfile', profileData)
setUser(response.data)
return { success: true, data: response.data }
})
}
const initializeAuth = async () => {
if (initialized.value || !token.value) return
initialized.value = true
try {
await fetchProfile()
console.log('Auth restored successfully')
} catch (err) {
console.error('Auth restoration failed:', err)
clearAuth()
}
}
const updateAvatar = async (avatarFile) => {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (avatarFile.size > MAX_FILE_SIZE) {
return {
success: false,
error: 'Размер файла не должен превышать 5MB'
};
}
// ✅ ПРОВЕРКА ТИПА ФАЙЛА
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(avatarFile.type)) {
return {
success: false,
error: 'Допустимые форматы: JPEG, PNG, GIF, '
};
}
const formData = new FormData()
formData.append('avatar', avatarFile)
try {
loading.value = true
error.value = ''
const response = await apiClient.post('/user/avatar/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
console.log('Avatar upload response:', response.data)
// Универсальная обработка ответа
let result
if (response.data.success !== undefined) {
result = response.data
} else {
// Если поле success отсутствует, считаем успешным
result = { success: true, ...response.data }
}
if (result.success) {
// ОБНОВЛЯЕМ ВЕСЬ ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ
await fetchProfile()
return { success: true, avatar: result.avatar }
} else {
return { success: false, error: result.error || result.message }
}
} catch (error) {
console.error('Avatar upload error:', error)
const result = handleApiError(error)
return result
} finally {
loading.value = false
}
}
const deleteAvatar = async () => {
try {
const response = await apiClient.delete('/user/avatar/delete')
if (response.data.success) {
// Удаляем аватар из стора
if (user.value) {
user.value.avatar = null
}
return { success: true }
}
} catch (error) {
const result = handleApiError(error)
return result
}
}
return {
// State
user,
token,
loading,
error,
initialized,
// Getters
isAuthenticated,
userFullName,
// Actions
register,
login,
logout,
fetchProfile,
updateProfile,
initializeAuth,
clearAuth,
updateAvatar,
deleteAvatar
}
})
+12
View File
@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
@@ -0,0 +1,80 @@
// stores/helpers/api.js
import axios from 'axios'
const API_BASE_URL = 'https://begushiybashkir.ru/api/v1'
// Создаем экземпляр axios с базовой конфигурацией
export const apiClient = axios.create({
baseURL: API_BASE_URL,
withCredentials: true
})
// Интерцептор для автоматического добавления токена
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Интерцептор для обработки ошибок
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// Утилита для обработки ошибок
export const handleApiError = (error) => {
const message = error.response?.data?.message || error.message || 'Произошла ошибка'
return { success: false, error: message }
}
// Утилита для выполнения запросов с loading state
export const withLoading = async (store, fn) => {
store.loading = true
store.error = ''
try {
return await fn()
} catch (error) {
const result = handleApiError(error)
store.error = result.error
return result
} finally {
store.loading = false
}
}
export const createLoadingHandler = (store) => {
return async (fn) => {
if (store && typeof store.loading !== 'undefined') {
store.loading = true
}
if (store && typeof store.error !== 'undefined') {
store.error = ''
}
try {
return await fn()
} catch (error) {
const result = handleApiError(error)
if (store && typeof store.error !== 'undefined') {
store.error = result.error
}
return result
} finally {
if (store && typeof store.loading !== 'undefined') {
store.loading = false
}
}
}
}
export const api = apiClient;
export default apiClient;
+6
View File
@@ -0,0 +1,6 @@
// stores/index.js
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
@@ -0,0 +1,7 @@
// stores/plugins/persistence.js
export const authPersistPlugin = ({ store }) => {
// Восстанавливаем состояние при инициализации
if (store.$id === 'auth') {
store.initializeAuth()
}
}
+138
View File
@@ -0,0 +1,138 @@
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { handleApiError } from './helpers/api'
export const useUserStore = defineStore('user', () => {
// State
const userStats = ref(null)
const userTraining = ref(null)
const userAchievements = ref([])
const loading = ref(false)
const error = ref('')
// Getters
const completedAchievements = computed(() =>
userAchievements.value.filter(achievement => achievement.achieved)
)
const pendingAchievements = computed(() =>
userAchievements.value.filter(achievement => !achievement.achieved)
)
const achievementProgress = computed(() => {
if (!userAchievements.value.length) return 0
return Math.round((completedAchievements.value.length / userAchievements.value.length) * 100)
})
// Вспомогательная функция для обработки loading/error
const withStoreLoading = async (fn) => {
loading.value = true
error.value = ''
try {
return await fn()
} catch (err) {
const result = handleApiError(err)
error.value = result.error
return result
} finally {
loading.value = false
}
}
// Actions
const fetchUserStats = async () => {
return withStoreLoading(async () => {
// TODO: Заменить на реальный endpoint когда будет готов
// const response = await apiClient.get('/user/stats')
// Временные мок данные
await new Promise(resolve => setTimeout(resolve, 500))
userStats.value = {
totalDistance: 245,
bestResult: '10км - 48:15',
totalWorkouts: 36,
weeklyDistance: 25,
monthlyDistance: 98,
avgPace: '5:15',
caloriesBurned: 12450
}
return { success: true, data: userStats.value }
})
}
const fetchUserTraining = async () => {
return withStoreLoading(async () => {
// TODO: Заменить на реальный endpoint когда будет готов
// const response = await apiClient.get('/user/training')
await new Promise(resolve => setTimeout(resolve, 500))
userTraining.value = {
currentWeek: 4,
totalWeeks: 12,
nextWorkout: '2024-03-20T18:00:00',
workouts: [
{ id: 1, date: '2024-03-18', type: 'interval', distance: '8km', completed: true },
{ id: 2, date: '2024-03-20', type: 'tempo', distance: '10km', completed: false },
{ id: 3, date: '2024-03-22', type: 'long', distance: '15km', completed: false }
]
}
return { success: true, data: userTraining.value }
})
}
const fetchUserAchievements = async () => {
return withStoreLoading(async () => {
// TODO: Заменить на реальный endpoint когда будет готов
// const response = await apiClient.get('/user/achievements')
await new Promise(resolve => setTimeout(resolve, 500))
userAchievements.value = [
{ id: 1, name: 'Первый забег', description: 'Пробежать первую 5км', achieved: true, date: '2024-01-20' },
{ id: 2, name: 'Неделя тренировок', description: 'Тренироваться 7 дней подряд', achieved: true, date: '2024-02-15' },
{ id: 3, name: '100 км', description: 'Пробежать 100 км', achieved: true, date: '2024-03-01' },
{ id: 4, name: 'Полумарафон', description: 'Пробежать 21.1 км', achieved: false },
{ id: 5, name: 'Скорость', description: 'Пробежать 5км быстрее 25 минут', achieved: false }
]
return { success: true, data: userAchievements.value }
})
}
// Пакетная загрузка всех данных пользователя
const fetchAllUserData = async () => {
return withStoreLoading(async () => {
await Promise.all([
fetchUserStats(),
fetchUserTraining(),
fetchUserAchievements()
])
return { success: true }
})
}
return {
// State
userStats,
userTraining,
userAchievements,
loading,
error,
// Getters
completedAchievements,
pendingAchievements,
achievementProgress,
// Actions
fetchUserStats,
fetchUserTraining,
fetchUserAchievements,
fetchAllUserData
}
})
+775
View File
@@ -0,0 +1,775 @@
<template>
<div class="about-page">
<!-- Герой-секция -->
<section class="hero-section">
<div class="container">
<h1 class="hero-title">О беговом клубе "Бегущий Башкир"</h1>
<p class="hero-subtitle">Объединяем любителей бега в Уфе с 2022 года</p>
</div>
</section>
<!-- О клубе -->
<section class="section club-info">
<div class="container">
<div class="info-grid">
<div class="info-content">
<h2>Наша философия</h2>
<p class="lead">
Мы верим, что бег это не просто спорт, а образ жизни, который объединяет людей,
укрепляет здоровье и открывает новые горизонты.
</p>
<div class="mission-vision">
<div class="mission-card">
<h3>🎯 Наша миссия</h3>
<p>Сделать бег доступным и enjoyable для каждого жителя Уфы, независимо от возраста и уровня подготовки.
</p>
</div>
<div class="mission-card">
<h3>👁 Наше видение</h3>
<p>Стать крупнейшим беговым сообществом в Башкортостане, которое вдохновляет на здоровый образ жизни.
</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-number">150+</div>
<div class="stat-label">Участников</div>
</div>
<div class="stat-item">
<div class="stat-number">25+</div>
<div class="stat-label">Мероприятий в год</div>
</div>
<div class="stat-item">
<div class="stat-number">42.2</div>
<div class="stat-label">км лучший марафон</div>
</div>
<div class="stat-item">
<div class="stat-number">100%</div>
<div class="stat-label">Дружеская атмосфера</div>
</div>
</div>
</div>
<div class="info-image">
<img :src="getImageUrl('UMM2025.png')" alt="Команда бегового клуба Бегущий Башкир" class="club-image">
</div>
</div>
</div>
</section>
<!-- Наши ценности -->
<section class="section values-section">
<div class="container">
<h2 class="section-title">Наши ценности</h2>
<div class="values-grid">
<div class="value-card">
<div class="value-icon">🤝</div>
<h3>Поддержка</h3>
<p>Мы поддерживаем друг друга на каждом километре, как на тренировках, так и на соревнованиях.</p>
</div>
<div class="value-card">
<div class="value-icon">📈</div>
<h3>Развитие</h3>
<p>Помогаем каждому участнику прогрессировать и достигать личных рекордов.</p>
</div>
<div class="value-card">
<div class="value-icon">🌿</div>
<h3>Единение с природой</h3>
<p>Тренируемся в парках и на природе, наслаждаясь свежим воздухом Уфы.</p>
</div>
<div class="value-card">
<div class="value-icon">🏆</div>
<h3>Спортивный дух</h3>
<p>Стремимся к победам, но ценим участие и личный прогресс выше медалей.</p>
</div>
</div>
</div>
</section>
<!-- Тренер -->
<section class="section coach-section">
<div class="container">
<h2 class="section-title">Наш тренер</h2>
<div class="coach-profile">
<div class="coach-image">
<img :src="getImageUrl('/ZagirTrainer3.jpg')" alt="Аминев Загир - тренер бегового клуба Бегущий Башкир"
class="coach-photo">
<div class="coach-badges">
<span class="badge">Мастер спорта</span>
<span class="badge">КМС</span>
<span class="badge">Опыт 10+ лет</span>
</div>
</div>
<div class="coach-details">
<h3>Аминев Загир Рамилевич</h3>
<p class="coach-title">Основатель и главный тренер клуба</p>
<div class="coach-achievements">
<h4>Спортивные достижения:</h4>
<ul class="achievements-list">
<li>🥇 Мастер спорта по полиатлону</li>
<li>🥈 Кандидат в мастера спорта по скайраннингу</li>
<li>🏆 Победитель всероссийских соревнований по горному бегу</li>
<li>🎯 Призер международных стартов по трейлу</li>
<li>📚 Сертифицированный тренер по бегу</li>
</ul>
</div>
<div class="coach-philosophy">
<h4>Философия тренировок:</h4>
<blockquote>
"Бег должен приносить радость! Я помогаю каждому найти свой темп,
полюбить процесс и безопасно достигать целей. От первых 5 км до марафона —
мы пройдем этот путь вместе!"
</blockquote>
</div>
<div class="coach-stats">
<div class="coach-stat">
<div class="stat-value">500+</div>
<div class="stat-desc">подготовленных бегунов</div>
</div>
<div class="coach-stat">
<div class="stat-value">100+</div>
<div class="stat-desc">проведенных марафонов</div>
</div>
<div class="coach-stat">
<div class="stat-value">10+</div>
<div class="stat-desc">лет тренерского опыта</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Тренировочные локации -->
<section class="section locations-section">
<div class="container">
<h2 class="section-title">Наши тренировочные базы</h2>
<div class="locations-grid">
<div class="location-card">
<div class="location-image">
<img :src="getImageUrl('UfaWhiteRiver.jpg')"
alt="Уфимская набережная (ост. Монумент) - основная тренировочная площадка">
</div>
<div class="location-info">
<h3>🏞 Набережная </h3>
<p><strong>Основная площадка</strong></p>
<p>Идеальные беговые дорожки, освещенная трасса, прекрасный воздух</p>
<div class="location-features">
<span class="feature">📏 Круг 5 км</span>
<span class="feature">💡 Освещение</span>
<span class="feature">🚽 Уборная</span>
</div>
</div>
</div>
<div class="location-card">
<div class="location-image">
<img :src="getImageUrl('dinamo.jpg')" alt="Стадион Динамо - для интервальных тренировок">
</div>
<div class="location-info">
<h3>🏟 Стадион "Динамо"</h3>
<p><strong>Интервальные тренировки</strong></p>
<p>Профессиональное покрытие, идеально для работы над техникой и скоростью</p>
<div class="location-features">
<span class="feature">📏 Стандартная дорожка</span>
<span class="feature">🎯 Тренерский контроль</span>
<span class="feature"> СБУ и темповая работа</span>
</div>
</div>
</div>
<div class="location-card">
<div class="location-image">
<img :src="getImageUrl('i.webp')" alt="Лесопарковая зона - для длительных кроссов. Уфимское ожерелье">
</div>
<div class="location-info">
<h3>🌲 Лесопарковая зона</h3>
<p><strong>Длительные кроссы</strong></p>
<p>Живописные маршруты, подготовка к трейлам, бег по пересеченной местности</p>
<div class="location-features">
<span class="feature">🌳 Природа</span>
<span class="feature">🔄 Маршруты 5-20 км</span>
<span class="feature">🏔 Перепады высот</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Призыв к действию -->
<section class="section cta-section">
<div class="container">
<div class="cta-content">
<h2>Готовы начать бегать с нами?</h2>
<p>Присоединяйтесь к нашей беговой семье и откройте для себя мир возможностей!</p>
<div class="cta-buttons">
<router-link to="/register" class="btn btn-primary">
🏃 Стать участником
</router-link>
<router-link to="/training" class="btn btn-secondary">
📅 Посмотреть расписание
</router-link>
<router-link to="/contact" class="btn btn-outline">
📞 Связаться с нами
</router-link>
</div>
</div>
</div>
</section>
<!-- Контакты -->
<section class="section contact-section">
<div class="container">
<h2 class="section-title">Контакты</h2>
<div class="contact-grid">
<div class="contact-info">
<h3>📞 Свяжитесь с нами</h3>
<div class="contact-item">
<strong>Телефон:</strong>
<a href="tel:+79273093095"> +7 (927) 30-93-095</a>
</div>
<div class="contact-item">
<strong>Email:</strong>
<a href="mailto:zog1r@mail.ru"> zog1r@mail.ru</a>
</div>
<div class="contact-item">
<strong>Telegram:</strong>
<a href="https://t.me/begushiybashkir" target="_blank"> @begushiybashkir</a>
</div>
<div class="contact-item">
<strong>Город:</strong>
Уфа, Республика Башкортостан
</div>
</div>
<div class="social-links">
<h3>📱 Мы в соцсетях</h3>
<div class="social-buttons">
<a href="https://www.instagram.com/begushiybashkir/" target="_blank" class="social-btn instagram">
📷 Instagram
</a>
<a href="https://www.youtube.com/channel/UCV45f8q172917848k05q6gA" target="_blank"
class="social-btn youtube">
🎥 YouTube
</a>
<a href="https://t.me/begushiybashkir" target="_blank" class="social-btn telegram">
Telegram
</a>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'About',
metaInfo: {
title: 'О нас - Бегущий Башкир | Беговой клуб в Уфе',
meta: [
{
name: 'description',
content: 'Беговой клуб Бегущий Башкир в Уфе: профессиональный тренер Аминев Загир, тренировки в парках Уфы, достижения и философия клуба. Присоединяйтесь к нашему сообществу!'
},
{
name: 'keywords',
content: 'беговой клуб Уфа, тренер по бегу Уфа, Аминев Загир, тренировки бег Уфа, марафон Уфа, бег в Башкортостане'
}
]
},
methods: {
getImageUrl(path) {
// В продакшене замените на правильный путь
const baseUrl = import.meta.env.BASE_URL
// Путь от корня public/
console.log(`${baseUrl}images/${path}`)
return `${baseUrl}images/${path}`
},
}
}
</script>
<style scoped>
.about-page {
color: #333;
}
/* Герой-секция */
.hero-section {
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
padding: 80px 0;
text-align: center;
}
.hero-title {
font-size: 3rem;
margin-bottom: 1rem;
font-weight: 700;
}
.hero-subtitle {
font-size: 1.3rem;
opacity: 0.9;
}
/* Основные секции */
.section {
padding: 80px 0;
}
.section-title {
text-align: center;
font-size: 2.5rem;
color: #2e8b57;
margin-bottom: 3rem;
font-weight: 700;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Информация о клубе */
.club-info {
background: #f8fff8;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
}
.lead {
font-size: 1.2rem;
line-height: 1.6;
margin-bottom: 2rem;
color: #555;
}
.mission-vision {
display: grid;
gap: 1.5rem;
margin: 2rem 0;
}
.mission-card {
background: white;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
border-left: 4px solid #2e8b57;
}
.mission-card h3 {
color: #2e8b57;
margin-bottom: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-top: 2rem;
}
.stat-item {
text-align: center;
padding: 1rem;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #2e8b57;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
.club-image {
width: 100%;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
/* Ценности */
.values-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.value-card {
background: white;
padding: 2rem;
border-radius: 15px;
text-align: center;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
}
.value-card:hover {
transform: translateY(-5px);
}
.value-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.value-card h3 {
color: #2e8b57;
margin-bottom: 1rem;
}
/* Тренер */
.coach-section {
background: #f5f5f5;
}
.coach-profile {
display: grid;
grid-template-columns: 400px 1fr;
gap: 3rem;
align-items: start;
}
.coach-image {
position: relative;
}
.coach-photo {
width: 100%;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.coach-badges {
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
}
.badge {
background: #ffd700;
color: #333;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: bold;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
.coach-details h3 {
font-size: 2rem;
color: #2e8b57;
margin-bottom: 0.5rem;
}
.coach-title {
font-size: 1.2rem;
color: #666;
margin-bottom: 2rem;
font-style: italic;
}
.coach-achievements,
.coach-philosophy {
margin-bottom: 2rem;
}
.coach-achievements h4,
.coach-philosophy h4 {
color: #2e8b57;
margin-bottom: 1rem;
}
.achievements-list {
list-style: none;
padding: 0;
}
.achievements-list li {
padding: 0.5rem 0;
font-size: 1.1rem;
}
blockquote {
background: white;
padding: 1.5rem;
border-left: 4px solid #2e8b57;
font-style: italic;
color: #555;
margin: 1rem 0;
border-radius: 0 8px 8px 0;
}
.coach-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-top: 2rem;
}
.coach-stat {
text-align: center;
padding: 1rem;
background: white;
border-radius: 10px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: #2e8b57;
}
.stat-desc {
font-size: 0.9rem;
color: #666;
}
/* Локации */
.locations-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
}
.location-card {
background: white;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
}
.location-card:hover {
transform: translateY(-5px);
}
.location-image img {
width: 100%;
height: 200px;
object-fit: cover;
}
.location-info {
padding: 1.5rem;
}
.location-info h3 {
color: #2e8b57;
margin-bottom: 1rem;
}
.location-features {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.feature {
background: #f0f8f0;
padding: 0.3rem 0.8rem;
border-radius: 15px;
font-size: 0.8rem;
color: #2e8b57;
}
/* CTA секция */
.cta-section {
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
text-align: center;
}
.cta-content h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.cta-content p {
font-size: 1.2rem;
margin-bottom: 2rem;
opacity: 0.9;
}
.cta-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 15px 30px;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1.1rem;
}
.btn-primary {
background: #ffd700;
color: #333;
}
.btn-primary:hover {
background: #e6c200;
transform: translateY(-2px);
}
.btn-secondary {
background: white;
color: #2e8b57;
}
.btn-secondary:hover {
background: #f5f5f5;
transform: translateY(-2px);
}
.btn-outline {
background: transparent;
color: white;
border: 2px solid white;
}
.btn-outline:hover {
background: white;
color: #2e8b57;
transform: translateY(-2px);
}
/* Контакты */
.contact-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
}
.contact-item {
margin-bottom: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.contact-item a {
color: #2e8b57;
text-decoration: none;
}
.contact-item a:hover {
text-decoration: underline;
}
.social-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
}
.social-btn {
display: inline-block;
padding: 12px 20px;
border-radius: 8px;
text-decoration: none;
color: white;
font-weight: bold;
transition: transform 0.3s ease;
text-align: center;
}
.social-btn:hover {
transform: translateY(-2px);
}
.instagram {
background: #E4405F;
}
.youtube {
background: #CD201F;
}
.telegram {
background: #0088cc;
}
/* Адаптивность */
@media (max-width: 768px) {
.hero-title {
font-size: 2rem;
}
.info-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.coach-profile {
grid-template-columns: 1fr;
}
.coach-stats {
grid-template-columns: 1fr;
}
.locations-grid {
grid-template-columns: 1fr;
}
.contact-grid {
grid-template-columns: 1fr;
}
.cta-buttons {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 300px;
}
}
</style>
+992
View File
@@ -0,0 +1,992 @@
<template>
<div class="achievements-page">
<!-- Герой-секция -->
<section class="hero-section">
<div class="container">
<h1 class="hero-title">🏆 Наши достижения</h1>
<p class="hero-subtitle">Гордимся каждым участником и каждым результатом!</p>
<div class="hero-stats">
<div class="stat">
<div class="stat-number">{{ totalAchievements }}</div>
<div class="stat-label">Достижений</div>
</div>
<div class="stat">
<div class="stat-number">{{ activeMembers }}</div>
<div class="stat-label">Активных участников</div>
</div>
<div class="stat">
<div class="stat-number">{{ personalBests }}</div>
<div class="stat-label">Личных рекордов</div>
</div>
</div>
</div>
</section>
<!-- Фильтры и поиск -->
<section class="filters-section">
<div class="container">
<div class="filters-container">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
placeholder="🔍 Поиск по имени или дистанции..."
class="search-input"
>
</div>
<div class="filter-buttons">
<button
v-for="filter in filters"
:key="filter.value"
:class="['filter-btn', { 'active': activeFilter === filter.value }]"
@click="setFilter(filter.value)"
>
{{ filter.label }}
</button>
</div>
<div class="view-toggle">
<button
:class="['view-btn', { 'active': viewMode === 'grid' }]"
@click="viewMode = 'grid'"
title="Сетка"
>
</button>
<button
:class="['view-btn', { 'active': viewMode === 'list' }]"
@click="viewMode = 'list'"
title="Список"
>
</button>
</div>
</div>
</div>
</section>
<!-- Достижения -->
<section class="achievements-section">
<div class="container">
<!-- Командные достижения -->
<div class="category-section">
<h2 class="category-title">🏆 Командные достижения</h2>
<div class="achievements-grid" :class="viewMode">
<div
v-for="achievement in teamAchievements"
:key="achievement.id"
class="achievement-card team-card"
>
<div class="achievement-icon">🏅</div>
<h3>{{ achievement.title }}</h3>
<p>{{ achievement.description }}</p>
<div class="achievement-meta">
<span class="year">{{ achievement.year }}</span>
<span class="type">{{ achievement.type }}</span>
</div>
</div>
</div>
</div>
<!-- Личные достижения по дистанциям -->
<div
v-for="category in filteredCategories"
:key="category.id"
class="category-section"
>
<h2 class="category-title">{{ category.icon }} {{ category.title }}</h2>
<div class="achievements-grid" :class="viewMode">
<div
v-for="achievement in category.achievements"
:key="achievement.id"
class="achievement-card"
:class="getAchievementClass(achievement)"
>
<div class="achievement-header">
<h3>{{ achievement.name }}</h3>
<span class="result">{{ achievement.result }}</span>
</div>
<div class="achievement-details">
<p class="pace" v-if="achievement.pace">Темп: {{ achievement.pace }}</p>
<p class="note" v-if="achievement.note">{{ achievement.note }}</p>
<div class="achievement-links" v-if="achievement.telegram">
<a
:href="achievement.telegram"
target="_blank"
class="telegram-link"
title="Написать в Telegram"
>
📱 @{{ getTelegramUsername(achievement.telegram) }}
</a>
</div>
<div class="achievement-meta">
<span
v-if="achievement.pb"
class="badge pb-badge"
title="Личный рекорд"
>
PB
</span>
<span class="distance">{{ category.distance }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика -->
<div class="stats-section">
<h2 class="section-title">📊 Статистика клуба</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">🚀</div>
<div class="stat-content">
<h3>Самый быстрый марафон</h3>
<p class="stat-value">3:27:49</p>
<p class="stat-person">Сергей</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏔</div>
<div class="stat-content">
<h3>Самая длинная дистанция</h3>
<p class="stat-value">120 км</p>
<p class="stat-person">Даниил Хайбуллин</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-content">
<h3>Больше всего PB</h3>
<p class="stat-value">5 рекордов</p>
<p class="stat-person">Ғаяз</p>
</div>
</div>
</div>
</div>
<!-- Призыв к действию -->
<div class="cta-section">
<div class="cta-content">
<h2>Хочешь попасть в этот список?</h2>
<p>Присоединяйся к нашему клубу и начни свой путь к новым достижениям!</p>
<div class="cta-buttons">
<router-link to="/training" class="btn btn-primary">
📅 Начать тренироваться
</router-link>
<router-link to="/register" class="btn btn-secondary">
🏃 Вступить в клуб
</router-link>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Achievements',
data() {
return {
searchQuery: '',
activeFilter: 'all',
viewMode: 'grid',
filters: [
{ value: 'all', label: 'Все' },
{ value: 'marathon', label: 'Марафон' },
{ value: 'half', label: 'Полумарафон' },
{ value: 'short', label: 'Короткие' },
{ value: 'trail', label: 'Трейл' }
],
teamAchievements: [
{
id: 1,
title: 'Эстафета 4×400м',
description: 'III место среди беговых клубов',
year: '2024, 2025',
type: 'Командное'
}
],
achievementCategories: [
{
id: 'marathon',
title: 'Марафон 42.2 км',
icon: '🏃',
distance: '42.2 км',
achievements: [
{
id: 1,
name: 'Сергей',
result: '3:27:49',
telegram: 'https://t.me/Sergeicortess',
pb: true
},
{
id: 2,
name: 'Ғаяз',
result: '3:34:33',
telegram: 'https://t.me/GeniusUfa',
pb: true
}
]
},
{
id: 'half',
title: 'Полумарафон 21.1 км',
icon: '🎯',
distance: '21.1 км',
achievements: [
{
id: 1,
name: 'Ильгам',
result: '1:23:33',
telegram: 'https://t.me/Ilgam25883',
pb: true
},
{
id: 2,
name: 'Данил',
result: '1:30:40',
pace: "4'16",
telegram: 'https://t.me/Khaybullin_D',
note: 'PB',
pb: true
},
{
id: 3,
name: 'Ғаяз',
result: '1:31:40',
pace: "4'20",
telegram: 'https://t.me/GeniusUfa',
note: 'PB',
pb: true
},
{
id: 4,
name: 'Булат',
result: '1:45:48',
pace: "5'00",
telegram: 'https://t.me/Bulat_Vakhitov',
note: 'PB',
pb: true
},
{
id: 5,
name: 'Ильвира',
result: '1:45:48',
pace: "5'00",
telegram: 'https://t.me/Yahina_Ilvira',
note: 'PB',
pb: true
},
{
id: 6,
name: 'Булат',
result: '2:08:30',
pace: "6'05",
telegram: 'https://t.me/Bulatiwe',
note: 'PB',
pb: true
}
]
},
{
id: 'ten',
title: '10 км',
icon: '⚡',
distance: '10 км',
achievements: [
{
id: 1,
name: 'Эдуард',
result: '36:52',
pace: "3'41"
},
{
id: 2,
name: 'Илһам',
result: '37:59',
pace: "3'47",
telegram: 'https://t.me/Ilgam25883'
},
{
id: 3,
name: 'Арыҫлан',
result: '38:25',
pace: "3'50",
telegram: 'https://t.me/Just_Aryslan'
},
{
id: 4,
name: 'Ибрагимов Ринат',
result: '38:49'
},
{
id: 5,
name: 'Гүзәл Гузель Ахмадуллина',
result: '53:25',
pace: "5'20"
},
{
id: 6,
name: 'Финат Гайфуллин',
result: '56:46',
pace: "5'40"
},
{
id: 7,
name: 'Регина',
result: '59:43',
pace: "5'58",
telegram: 'https://t.me/massageregina'
}
]
},
{
id: 'five',
title: '5 км',
icon: '🎽',
distance: '5 км',
achievements: [
{
id: 1,
name: 'Ғәзиз',
result: '25:13',
pace: "5'02",
telegram: 'https://t.me/valitovgaziz',
note: 'PB',
pb: true
},
{
id: 2,
name: 'Зарема',
result: '28:22',
pace: "5'40",
telegram: 'https://t.me/am1neva',
note: 'PB',
pb: true
},
{
id: 3,
name: 'Камила',
result: '32:23',
pace: "6'28",
telegram: 'https://t.me/khayrutdinova_kamila'
},
{
id: 4,
name: 'Айгөл',
result: '37:23',
pace: "7'28",
telegram: 'https://t.me/Aigulika_Elis'
}
]
},
{
id: 'trail',
title: 'Трейловые дистанции',
icon: '🏔️',
distance: 'Разные дистанции',
achievements: [
{
id: 1,
name: 'Хайбуллин Даниил',
result: '120 км',
note: 'III место на Batyr BackYard Ultra',
pb: true
},
{
id: 2,
name: 'Ибрагимов Ринат',
result: '22 км',
note: 'III место на ультрамарафоне «Мир!Труд! Май!»',
pb: true
},
{
id: 3,
name: 'Хайбуллин Азамат',
result: '6 км',
note: 'III место на Karst trail',
pb: true
}
]
}
]
}
},
computed: {
totalAchievements() {
return this.achievementCategories.reduce((total, category) => {
return total + category.achievements.length
}, 0) + this.teamAchievements.length
},
activeMembers() {
const members = new Set()
this.achievementCategories.forEach(category => {
category.achievements.forEach(achievement => {
members.add(achievement.name)
})
})
return members.size
},
personalBests() {
let count = 0
this.achievementCategories.forEach(category => {
category.achievements.forEach(achievement => {
if (achievement.pb) count++
})
})
return count
},
filteredCategories() {
if (this.activeFilter === 'all' && !this.searchQuery) {
return this.achievementCategories
}
return this.achievementCategories
.filter(category => {
if (this.activeFilter === 'all') return true
if (this.activeFilter === 'short') {
return category.id === 'five' || category.id === 'ten'
}
return category.id === this.activeFilter
})
.map(category => ({
...category,
achievements: category.achievements.filter(achievement => {
if (!this.searchQuery) return true
const query = this.searchQuery.toLowerCase()
return (
achievement.name.toLowerCase().includes(query) ||
category.title.toLowerCase().includes(query) ||
(achievement.note && achievement.note.toLowerCase().includes(query))
)
})
}))
.filter(category => category.achievements.length > 0)
}
},
methods: {
setFilter(filter) {
this.activeFilter = filter
},
getTelegramUsername(url) {
return url.split('/').pop()
},
getAchievementClass(achievement) {
const classes = []
if (achievement.pb) classes.push('pb-achievement')
if (achievement.telegram) classes.push('has-contact')
return classes
}
}
}
</script>
<style scoped>
.achievements-page {
min-height: 100vh;
background: linear-gradient(135deg, #f8fff8 0%, #f0f8f0 100%);
}
/* Герой-секция */
.hero-section {
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
padding: 80px 0 60px;
text-align: center;
}
.hero-title {
font-size: 3rem;
margin-bottom: 1rem;
font-weight: 800;
}
.hero-subtitle {
font-size: 1.3rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.hero-stats {
display: flex;
justify-content: center;
gap: 3rem;
flex-wrap: wrap;
margin-top: 2rem;
}
.stat {
text-align: center;
}
.stat-number {
font-size: 2.5rem;
font-weight: 800;
color: #ffd700;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.8;
}
/* Фильтры */
.filters-section {
background: white;
padding: 2rem 0;
border-bottom: 1px solid #e9ecef;
position: sticky;
top: 80px;
z-index: 100;
}
.filters-container {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 250px;
}
.search-input {
width: 100%;
padding: 12px 20px;
border: 2px solid #e9ecef;
border-radius: 25px;
font-size: 1rem;
transition: all 0.3s ease;
}
.search-input:focus {
outline: none;
border-color: #2e8b57;
box-shadow: 0 0 0 3px rgba(46, 139, 87, 0.1);
}
.filter-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 10px 20px;
border: 2px solid #e9ecef;
background: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.9rem;
white-space: nowrap;
}
.filter-btn:hover {
border-color: #2e8b57;
}
.filter-btn.active {
background: #2e8b57;
color: white;
border-color: #2e8b57;
}
.view-toggle {
display: flex;
gap: 0.5rem;
}
.view-btn {
padding: 10px 15px;
border: 2px solid #e9ecef;
background: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1.2rem;
}
.view-btn:hover {
border-color: #2e8b57;
}
.view-btn.active {
background: #2e8b57;
color: white;
border-color: #2e8b57;
}
/* Достижения */
.achievements-section {
padding: 3rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.category-section {
margin-bottom: 4rem;
}
.category-title {
font-size: 2rem;
color: #2e8b57;
margin-bottom: 2rem;
padding-bottom: 0.5rem;
border-bottom: 3px solid #ffd700;
}
/* Сетка достижений */
.achievements-grid {
display: grid;
gap: 1.5rem;
}
.achievements-grid.grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.achievements-grid.list {
grid-template-columns: 1fr;
}
.achievement-card {
background: white;
border-radius: 15px;
padding: 1.5rem;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border-left: 4px solid #2e8b57;
position: relative;
overflow: hidden;
}
.achievement-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.achievement-card.team-card {
border-left-color: #ffd700;
background: linear-gradient(135deg, #fff9e6 0%, #fff3cc 100%);
}
.achievement-card.pb-achievement {
border-left-color: #e74c3c;
background: linear-gradient(135deg, #ffe6e6 0%, #ffcccc 100%);
}
.achievement-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
text-align: center;
}
.achievement-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.achievement-header h3 {
color: #2e8b57;
margin: 0;
font-size: 1.3rem;
flex: 1;
}
.result {
background: #2e8b57;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: bold;
font-size: 1.1rem;
margin-left: 1rem;
}
.achievement-details {
space-y: 0.5rem;
}
.pace {
color: #666;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.note {
color: #e74c3c;
font-weight: 600;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.achievement-links {
margin: 1rem 0;
}
.telegram-link {
color: #0088cc;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s ease;
}
.telegram-link:hover {
color: #005580;
text-decoration: underline;
}
.achievement-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
}
.badge {
padding: 0.3rem 0.8rem;
border-radius: 15px;
font-size: 0.8rem;
font-weight: bold;
}
.pb-badge {
background: #e74c3c;
color: white;
}
.distance {
color: #666;
font-size: 0.8rem;
}
/* Статистика */
.stats-section {
margin: 4rem 0;
}
.section-title {
text-align: center;
font-size: 2.5rem;
color: #2e8b57;
margin-bottom: 3rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.stat-card {
background: white;
padding: 2rem;
border-radius: 15px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
text-align: center;
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-3px);
}
.stat-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.stat-value {
font-size: 2rem;
font-weight: 800;
color: #2e8b57;
margin: 0.5rem 0;
}
.stat-person {
color: #666;
font-style: italic;
}
/* CTA секция */
.cta-section {
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
padding: 4rem 2rem;
border-radius: 20px;
text-align: center;
margin-top: 4rem;
}
.cta-content h2 {
font-size: 2.2rem;
margin-bottom: 1rem;
}
.cta-content p {
font-size: 1.2rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.cta-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 15px 30px;
border-radius: 50px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
gap: 0.5rem;
font-size: 1.1rem;
}
.btn-primary {
background: #ffd700;
color: #333;
}
.btn-primary:hover {
background: #e6c200;
transform: translateY(-2px);
}
.btn-secondary {
background: transparent;
color: white;
border-color: white;
}
.btn-secondary:hover {
background: white;
color: #2e8b57;
transform: translateY(-2px);
}
/* Адаптивность */
@media (max-width: 768px) {
.hero-title {
font-size: 2.2rem;
}
.hero-stats {
gap: 2rem;
}
.stat-number {
font-size: 2rem;
}
.filters-container {
flex-direction: column;
align-items: stretch;
}
.search-box {
min-width: auto;
}
.filter-buttons {
justify-content: center;
}
.view-toggle {
align-self: center;
}
.achievements-grid.grid {
grid-template-columns: 1fr;
}
.achievement-header {
flex-direction: column;
align-items: flex-start;
}
.result {
margin-left: 0;
margin-top: 0.5rem;
align-self: flex-start;
}
.cta-buttons {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 300px;
}
}
@media (max-width: 480px) {
.hero-section {
padding: 60px 0 40px;
}
.hero-title {
font-size: 1.8rem;
}
.category-title {
font-size: 1.5rem;
}
.section-title {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.container {
padding: 0 15px;
}
}
/* Анимации */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.achievement-card {
animation: fadeInUp 0.6s ease;
}
</style>
+963
View File
@@ -0,0 +1,963 @@
<template>
<div class="gallery-page">
<!-- Герой-секция -->
<section class="hero-section">
<div class="container">
<h1 class="hero-title">📸 Наша галерея</h1>
<p class="hero-subtitle">Запечатленные моменты тренировок, соревнований и дружеских встреч</p>
<div class="hero-stats">
<div class="stat">
<div class="stat-number">24+</div>
<div class="stat-label">Фотографий</div>
</div>
<div class="stat">
<div class="stat-number">12+</div>
<div class="stat-label">Мероприятий</div>
</div>
<div class="stat">
<div class="stat-number">100%</div>
<div class="stat-label">Эмоций</div>
</div>
</div>
</div>
</section>
<!-- Основной слайдер -->
<section class="slider-section">
<div class="container">
<h2 class="section-title">Лучшие моменты</h2>
<div class="slider-container">
<div class="slider" :style="{ transform: `translateX(-${currentSlide * 100}%)` }">
<div v-for="(slide, index) in slides" :key="index" class="slide"
:class="{ 'active': currentSlide === index }">
<img :src="getImageUrl(slide.src)" :alt="slide.alt" class="slide-image" @load="imageLoaded(index)">
<div class="slide-overlay">
<div class="slide-content">
<h3>{{ slide.title }}</h3>
<p>{{ slide.description }}</p>
<span class="slide-date">{{ slide.date }}</span>
</div>
</div>
</div>
</div>
<!-- Навигация слайдера -->
<button class="slider-nav prev" @click="prevSlide" aria-label="Предыдущее фото">
</button>
<button class="slider-nav next" @click="nextSlide" aria-label="Следующее фото">
</button>
<!-- Индикаторы -->
<div class="slider-indicators">
<button v-for="(slide, index) in slides" :key="index"
:class="['indicator', { 'active': currentSlide === index }]" @click="goToSlide(index)"
:aria-label="`Перейти к фото ${index + 1}`"></button>
</div>
<!-- Счетчик -->
<div class="slider-counter">
{{ currentSlide + 1 }} / {{ slides.length }}
</div>
</div>
</div>
</section>
<!-- Мини-галерея -->
<section class="gallery-section">
<div class="container">
<h2 class="section-title">Все фотографии</h2>
<!-- Фильтры -->
<div class="gallery-filters">
<button v-for="filter in filters" :key="filter.value"
:class="['filter-btn', { 'active': activeFilter === filter.value }]" @click="setFilter(filter.value)">
{{ filter.label }}
</button>
</div>
<!-- Сетка фотографий -->
<div class="gallery-grid">
<div v-for="(image, index) in filteredImages" :key="index" class="gallery-item" @click="openLightbox(index)">
<div>{{ getImageUrl(image.src) }}</div>
<img :src="getImageUrl(image.src)" :alt="image.alt" class="gallery-image" loading="lazy">
<div class="gallery-overlay">
<div class="overlay-content">
<span class="image-date">{{ image.date }}</span>
<button class="zoom-btn" aria-label="Увеличить фото">
🔍
</button>
</div>
</div>
</div>
</div>
<!-- Кнопка загрузки еще -->
<div class="load-more" v-if="visibleImages < allImages.length">
<button class="btn btn-outline" @click="loadMore">
📁 Загрузить еще фото
</button>
</div>
</div>
</section>
<!-- Лайтбокс -->
<div class="lightbox" :class="{ 'active': lightboxActive }" v-if="lightboxActive">
<div class="lightbox-content">
<button class="lightbox-close" @click="closeLightbox" aria-label="Закрыть">
</button>
<button class="lightbox-nav prev" @click="prevLightbox" aria-label="Предыдущее фото">
</button>
<div class="lightbox-image-container">
<img :src="getImageUrl(currentLightboxImage.src)" :alt="currentLightboxImage.alt" class="lightbox-image">
<div class="lightbox-info">
<h3>{{ currentLightboxImage.title }}</h3>
<p>{{ currentLightboxImage.description }}</p>
<span class="lightbox-date">{{ currentLightboxImage.date }}</span>
</div>
</div>
<button class="lightbox-nav next" @click="nextLightbox" aria-label="Следующее фото">
</button>
</div>
<div class="lightbox-overlay" @click="closeLightbox"></div>
</div>
<!-- Призыв к действию -->
<section class="cta-section">
<div class="container">
<div class="cta-content">
<h2>Хочешь попасть в нашу галерею?</h2>
<p>Присоединяйся к нашей беговой семье и стань частью этих ярких моментов!</p>
<div class="cta-buttons">
<router-link to="/training" class="btn btn-primary">
📅 Посмотреть расписание
</router-link>
<router-link to="/register" class="btn btn-secondary">
🏃 Вступить в клуб
</router-link>
</div>
<div class="cta-features">
<div class="feature"> Бесплатная первая тренировка</div>
<div class="feature"> Профессиональный тренер</div>
<div class="feature"> Дружеская атмосфера</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Gallery',
data() {
return {
currentSlide: 0,
lightboxActive: false,
currentLightboxIndex: 0,
activeFilter: 'all',
visibleImages: 12,
slides: [
{
src: 'slider/slider24.jpg',
alt: 'Беговой клуб Бегущий Башкир, РосХим Стерлитамак Забег 2025',
title: 'РосХим Стерлитамак 2025',
description: 'Наши участники на крупном забеге',
date: 'Январь 2025'
},
{
src: 'slider/slider23.png',
alt: 'Беговой клуб, общее фото УММ 2025',
title: 'Уфимский марафон 2025',
description: 'Командное фото после успешного забега',
date: 'Январь 2025'
},
{
src: 'slider/slider1.jpg',
alt: 'Беговой клуб, общее фото',
title: 'Тренировка в парке',
description: 'Регулярные занятия на свежем воздухе',
date: 'Декабрь 2024'
},
{
src: 'slider/slider2.jpg',
alt: 'Беговой клуб, общее фото',
title: 'Техника бега',
description: 'Работа над правильной техникой',
date: 'Декабрь 2024'
},
{
src: 'slider/slider4.jpg',
alt: 'Беговой клуб, общее фото',
title: 'Групповая тренировка',
description: 'Поддержка и мотивация в команде',
date: 'Ноябрь 2024'
},
{
src: 'slider/slider5.jpg',
alt: 'Беговой клуб, общее фото',
title: 'Соревнования',
description: 'Участие в городских забегах',
date: 'Ноябрь 2024'
}
],
allImages: [
// Первые 6 - это слайды, остальные - дополнительные фото
{ src: 'slider/slider1.jpg', alt: 'Тренировка в парке', date: 'Декабрь 2024', category: 'training' },
{ src: 'slider/slider2.jpg', alt: 'Техника бега', date: 'Декабрь 2024', category: 'training' },
{ src: 'slider/slider4.jpg', alt: 'Групповая тренировка', date: 'Ноябрь 2024', category: 'training' },
{ src: 'slider/slider5.jpg', alt: 'Соревнования', date: 'Ноябрь 2024', category: 'events' },
{ src: 'slider/slider6.jpg', alt: 'Награждение', date: 'Ноябрь 2024', category: 'events' },
{ src: 'slider/slider7.jpg', alt: 'Командный дух', date: 'Октябрь 2024', category: 'community' },
{ src: 'slider/workout1.jpg', alt: 'Тренировка на набережной', date: 'Октябрь 2025', category: 'training' },
{ src: 'slider/workout2.jpg', alt: 'Тренировка на набережной', date: 'Октябрь 2025', category: 'training' },
{ src: 'slider/workout3.jpg', alt: 'Тренировка на набережной', date: 'Октябрь 2025', category: 'training' },
{ src: 'slider/slider8.jpg', alt: 'Вечерняя пробежка', date: 'Октябрь 2024', category: 'training' },
{ src: 'slider/slider9.jpg', alt: 'Растяжка после бега', date: 'Октябрь 2024', category: 'training' },
{ src: 'slider/slider10.jpg', alt: 'Общение после тренировки', date: 'Сентябрь 2024', category: 'community' },
{ src: 'slider/slider11.jpg', alt: 'Индивидуальные занятия', date: 'Сентябрь 2024', category: 'training' },
{ src: 'slider/slider12.jpg', alt: 'Подготовка к старту', date: 'Сентябрь 2024', category: 'events' },
{ src: 'slider/slider13.jpg', alt: 'Победа!', date: 'Август 2024', category: 'events' },
{ src: 'slider/slider14.jpg', alt: 'Трейл раннинг', date: 'Август 2024', category: 'training' },
{ src: 'slider/slider15.jpg', alt: 'Горные маршруты', date: 'Июль 2024', category: 'training' },
{ src: 'slider/slider16.jpg', alt: 'Летние тренировки', date: 'Июль 2024', category: 'training' },
{ src: 'slider/slider17.jpg', alt: 'Восстановление', date: 'Июнь 2024', category: 'training' },
{ src: 'slider/slider18.jpg', alt: 'Мастер-класс', date: 'Июнь 2024', category: 'events' },
{ src: 'slider/slider19.jpg', alt: 'Новички клуба', date: 'Май 2024', category: 'community' },
{ src: 'slider/slider20.jpg', alt: 'Весенний забег', date: 'Май 2024', category: 'events' },
{ src: 'slider/slider21.jpg', alt: 'Работа в группе', date: 'Апрель 2024', category: 'training' },
{ src: 'slider/slider22.jpg', alt: 'Совместный отдых', date: 'Апрель 2024', category: 'community' },
{ src: 'slider/slider23.png', alt: 'Уфимский марафон', date: 'Март 2024', category: 'events' },
{ src: 'slider/slider24.jpg', alt: 'РосХим Стерлитамак', date: 'Январь 2025', category: 'events' }
],
filters: [
{ value: 'all', label: 'Все фото' },
{ value: 'training', label: 'Тренировки' },
{ value: 'events', label: 'Соревнования' },
{ value: 'community', label: 'Мероприятия' }
],
slideInterval: null
}
},
computed: {
filteredImages() {
let filtered = this.allImages
if (this.activeFilter !== 'all') {
filtered = filtered.filter(image => image.category === this.activeFilter)
}
return filtered.slice(0, this.visibleImages)
},
currentLightboxImage() {
return this.allImages[this.currentLightboxIndex] || {}
}
},
methods: {
getImageUrl(path) {
// В продакшене замените на правильный путь
const baseUrl = import.meta.env.BASE_URL
// Путь от корня public/
console.log(`${baseUrl}images/${path}`)
return `${baseUrl}images/${path}`
},
nextSlide() {
this.currentSlide = (this.currentSlide + 1) % this.slides.length
},
prevSlide() {
this.currentSlide = (this.currentSlide - 1 + this.slides.length) % this.slides.length
},
goToSlide(index) {
this.currentSlide = index
},
openLightbox(index) {
this.currentLightboxIndex = index
this.lightboxActive = true
document.body.style.overflow = 'hidden'
},
closeLightbox() {
this.lightboxActive = false
document.body.style.overflow = ''
},
nextLightbox() {
this.currentLightboxIndex = (this.currentLightboxIndex + 1) % this.allImages.length
},
prevLightbox() {
this.currentLightboxIndex = (this.currentLightboxIndex - 1 + this.allImages.length) % this.allImages.length
},
setFilter(filter) {
this.activeFilter = filter
this.visibleImages = 12
},
loadMore() {
this.visibleImages += 6
},
startAutoSlide() {
this.slideInterval = setInterval(() => {
this.nextSlide()
}, 5000)
},
stopAutoSlide() {
if (this.slideInterval) {
clearInterval(this.slideInterval)
this.slideInterval = null
}
},
imageLoaded(index) {
console.log(`Image ${index} loaded successfully`)
},
handleKeydown(event) {
if (!this.lightboxActive) return
switch (event.key) {
case 'Escape':
this.closeLightbox()
break
case 'ArrowLeft':
this.prevLightbox()
break
case 'ArrowRight':
this.nextLightbox()
break
}
}
},
mounted() {
this.startAutoSlide()
document.addEventListener('keydown', this.handleKeydown)
},
beforeUnmount() {
this.stopAutoSlide()
document.removeEventListener('keydown', this.handleKeydown)
document.body.style.overflow = ''
}
}
</script>
<style scoped>
.gallery-page {
min-height: 100vh;
background: linear-gradient(135deg, #f8fff8 0%, #f0f8f0 100%);
}
/* Герой-секция */
.hero-section {
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
padding: 80px 0 60px;
text-align: center;
}
.hero-title {
font-size: 3rem;
margin-bottom: 1rem;
font-weight: 800;
}
.hero-subtitle {
font-size: 1.3rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.hero-stats {
display: flex;
justify-content: center;
gap: 3rem;
flex-wrap: wrap;
margin-top: 2rem;
}
.stat {
text-align: center;
}
.stat-number {
font-size: 2.5rem;
font-weight: 800;
color: #ffd700;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.8;
}
/* Основные стили */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.section-title {
text-align: center;
font-size: 2.5rem;
color: #2e8b57;
margin-bottom: 3rem;
font-weight: 700;
}
/* Слайдер */
.slider-section {
padding: 4rem 0;
}
.slider-container {
position: relative;
max-width: 900px;
margin: 0 auto;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.slider {
display: flex;
transition: transform 0.5s ease;
height: 500px;
}
.slide {
flex: 0 0 100%;
position: relative;
height: 100%;
}
.slide-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.slide-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
color: white;
padding: 2rem;
}
.slide-content h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.slide-content p {
opacity: 0.9;
margin-bottom: 0.5rem;
}
.slide-date {
font-size: 0.9rem;
opacity: 0.7;
}
/* Навигация слайдера */
.slider-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.9);
border: none;
width: 50px;
height: 50px;
border-radius: 50%;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
}
.slider-nav:hover {
background: white;
transform: translateY(-50%) scale(1.1);
}
.slider-nav.prev {
left: 20px;
}
.slider-nav.next {
right: 20px;
}
/* Индикаторы */
.slider-indicators {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 10;
}
.indicator {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid white;
background: transparent;
cursor: pointer;
transition: all 0.3s ease;
}
.indicator.active {
background: white;
transform: scale(1.2);
}
.slider-counter {
position: absolute;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
z-index: 10;
}
/* Галерея */
.gallery-section {
padding: 4rem 0;
background: white;
}
.gallery-filters {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 3rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 12px 24px;
border: 2px solid #e9ecef;
background: white;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
}
.filter-btn:hover {
border-color: #2e8b57;
}
.filter-btn.active {
background: #2e8b57;
color: white;
border-color: #2e8b57;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.gallery-item {
position: relative;
border-radius: 15px;
overflow: hidden;
cursor: pointer;
aspect-ratio: 4/3;
transition: all 0.3s ease;
}
.gallery-item:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.gallery-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.gallery-item:hover .gallery-image {
transform: scale(1.05);
}
.gallery-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
align-items: flex-end;
padding: 1rem;
}
.gallery-item:hover .gallery-overlay {
opacity: 1;
}
.overlay-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
color: white;
}
.image-date {
font-size: 0.9rem;
opacity: 0.9;
}
.zoom-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 8px;
border-radius: 50%;
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.zoom-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.load-more {
text-align: center;
}
/* Лайтбокс */
.lightbox {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.lightbox.active {
opacity: 1;
visibility: visible;
}
.lightbox-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(5px);
}
.lightbox-content {
position: relative;
z-index: 1001;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.lightbox-close {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 2rem;
width: 50px;
height: 50px;
border-radius: 50%;
cursor: pointer;
z-index: 1002;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
cursor: pointer;
z-index: 1002;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.lightbox-nav:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-50%) scale(1.1);
}
.lightbox-nav.prev {
left: 20px;
}
.lightbox-nav.next {
right: 20px;
}
.lightbox-image-container {
max-width: 90%;
max-height: 90%;
position: relative;
}
.lightbox-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 10px;
}
.lightbox-info {
background: rgba(255, 255, 255, 0.95);
padding: 1.5rem;
border-radius: 0 0 10px 10px;
margin-top: -5px;
}
.lightbox-info h3 {
color: #2e8b57;
margin-bottom: 0.5rem;
}
.lightbox-info p {
color: #666;
margin-bottom: 0.5rem;
}
.lightbox-date {
color: #999;
font-size: 0.9rem;
}
/* CTA секция */
.cta-section {
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
padding: 4rem 0;
text-align: center;
}
.cta-content h2 {
font-size: 2.2rem;
margin-bottom: 1rem;
}
.cta-content p {
font-size: 1.2rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.cta-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.cta-features {
display: flex;
justify-content: center;
gap: 2rem;
flex-wrap: wrap;
}
.feature {
font-size: 1.1rem;
opacity: 0.9;
}
/* Кнопки */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 15px 30px;
border-radius: 50px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
gap: 0.5rem;
font-size: 1.1rem;
cursor: pointer;
}
.btn-primary {
background: #ffd700;
color: #333;
}
.btn-primary:hover {
background: #e6c200;
transform: translateY(-2px);
}
.btn-secondary {
background: transparent;
color: white;
border-color: white;
}
.btn-secondary:hover {
background: white;
color: #2e8b57;
transform: translateY(-2px);
}
.btn-outline {
background: transparent;
color: #2e8b57;
border-color: #2e8b57;
}
.btn-outline:hover {
background: #2e8b57;
color: white;
transform: translateY(-2px);
}
/* Адаптивность */
@media (max-width: 768px) {
.hero-title {
font-size: 2.2rem;
}
.hero-stats {
gap: 2rem;
}
.stat-number {
font-size: 2rem;
}
.slider {
height: 300px;
}
.slider-nav {
width: 40px;
height: 40px;
font-size: 1.2rem;
}
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.cta-buttons {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 300px;
}
.lightbox-nav {
width: 50px;
height: 50px;
font-size: 1.5rem;
}
.lightbox-content {
padding: 1rem;
}
}
@media (max-width: 480px) {
.hero-section {
padding: 60px 0 40px;
}
.hero-title {
font-size: 1.8rem;
}
.section-title {
font-size: 2rem;
}
.gallery-grid {
grid-template-columns: 1fr;
}
.gallery-filters {
flex-direction: column;
align-items: center;
}
.filter-btn {
width: 100%;
max-width: 200px;
}
.cta-features {
flex-direction: column;
gap: 1rem;
}
}
/* Анимации */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.gallery-item {
animation: fadeIn 0.6s ease;
}
</style>
+749
View File
@@ -0,0 +1,749 @@
<template>
<div class="home-page">
<!-- Герой-секция -->
<section class="hero-section">
<div class="hero-overlay">
<div class="container">
<div class="hero-content">
<h1 class="hero-title">
<span class="title-main">Стань лучшей версией себя</span>
<span class="title-sub">в беговом клубе "Бегущий Башкир"</span>
</h1>
<p class="hero-description">
Присоединяйся к самому дружному беговому сообществу Уфы.
Начни свой путь к здоровью, новым достижениям и знакомствам с единомышленниками.
</p>
<div class="hero-actions">
<router-link to="/register" class="btn btn-primary btn-large">
🏃 Начать бегать
</router-link>
<router-link to="/about" class="btn btn-outline btn-large">
👥 Узнать о клубе
</router-link>
</div>
<div class="hero-stats">
<div class="stat">
<div class="stat-number">80+</div>
<div class="stat-label">Участников</div>
</div>
<div class="stat">
<div class="stat-number">25+</div>
<div class="stat-label">Мероприятий в год</div>
</div>
<div class="stat">
<div class="stat-number">100%</div>
<div class="stat-label">Поддержка</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Преимущества -->
<section class="benefits-section">
<div class="container">
<h2 class="section-title">Почему выбирают нас?</h2>
<div class="benefits-grid">
<div class="benefit-card">
<div class="benefit-icon">👨🏫</div>
<h3>Профессиональный тренер</h3>
<p>Мастер спорта с индивидуальным подходом к каждому участнику</p>
</div>
<div class="benefit-card">
<div class="benefit-icon">🌳</div>
<h3>Тренировки на природе</h3>
<p>Занятия в лучших парках Уфы с чистым воздухом</p>
</div>
<div class="benefit-card">
<div class="benefit-icon">🤝</div>
<h3>Дружное сообщество</h3>
<p>Поддержка и мотивация от таких же любителей бега</p>
</div>
<div class="benefit-card">
<div class="benefit-icon">📈</div>
<h3>Личный прогресс</h3>
<p>От первых километров до марафонских дистанций</p>
</div>
</div>
</div>
</section>
<!-- Ближайшие события -->
<section class="events-section">
<div class="container">
<h2 class="section-title">Ближайшие события</h2>
<div class="events-grid">
<div class="event-card">
<div class="event-date">
<span class="date-day">Пн</span>
<span class="date-month">Янв</span>
</div>
<div class="event-info">
<h3>Открытая тренировка для новичков</h3>
<p>🏞 Набережная (ост. Монумент) 19:30</p>
<span class="event-tag free">Бесплатно</span>
</div>
</div>
<div class="event-card">
<div class="event-date">
<span class="date-day">Ср</span>
<span class="date-month">Янв</span>
</div>
<div class="event-info">
<h3>Техника бега + ОФП</h3>
<p>🏟 Стадион Динамо 19:30</p>
<span class="event-tag regular">Для всех уровней</span>
</div>
</div>
<div class="event-card">
<div class="event-date">
<span class="date-day">Суб</span>
<span class="date-month">Янв</span>
</div>
<div class="event-info">
<h3>Воскресный длительный кросс</h3>
<p>🌲 Лесопарковая зона 10:00</p>
<span class="event-tag long">5 - 10 км</span>
</div>
</div>
</div>
<div class="events-actions">
<router-link to="/training" class="btn btn-secondary">
📅 Все тренировки
</router-link>
</div>
</div>
</section>
<!-- История успеха -->
<section class="success-section">
<div class="container">
<h2 class="section-title">Истории успеха</h2>
<div class="success-stories">
<div class="story-card">
<div class="story-avatar">С</div>
<div class="story-content">
<h3>Сергей</h3>
<p class="story-achievement">Первый марафон за 3:27</p>
<p class="story-text">"Пришел в клуб с нуля, через год пробежал свой первый марафон. Спасибо тренеру и
команде за поддержку!"</p>
</div>
</div>
<div class="story-card">
<div class="story-avatar">Д</div>
<div class="story-content">
<h3>Данил</h3>
<p class="story-achievement">Ультрамарафон 120 км</p>
<p class="story-text">"Никогда не думал, что смогу пробежать 120 км. В клубе нашел не только тренера, но и
верных друзей."</p>
</div>
</div>
<div class="story-card">
<div class="story-avatar">А</div>
<div class="story-content">
<h3>Анна</h3>
<p class="story-achievement">Похудение на 15 кг</p>
<p class="story-text">"Бег изменил мою жизнь! Сбросила вес, нашла новых друзей и полюбила активный образ
жизни."</p>
</div>
</div>
</div>
</div>
</section>
<!-- Призыв к действию -->
<section class="cta-section">
<div class="container">
<div class="cta-content">
<h2>Готовы изменить свою жизнь?</h2>
<p>Присоединяйся к 80+ участникам, которые уже начали свой беговой путь</p>
<div class="cta-features">
<div class="cta-feature"> Первая пробная тренировка бесплатно</div>
<div class="cta-feature"> Подбор программы под ваш уровень</div>
<div class="cta-feature"> Поддержка тренера и сообщества</div>
</div>
<div class="cta-actions">
<router-link to="/register" class="btn btn-primary btn-cta">
🏃 Начать бесплатно
</router-link>
<div class="cta-contacts">
<p>Или напиши нам:</p>
<div class="contact-links">
<a href="https://t.me/zagir_aminev" class="contact-link">📱 Telegram</a>
<a href="tel:+79273093095" class="contact-link" aria-label="Позвонить по номеру +7 (927) 30-93-095"
tabindex="0">
📞 Связаться
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Быстрые ссылки -->
<section class="quick-links-section">
<div class="container">
<h2 class="section-title">Исследуйте клуб</h2>
<div class="quick-links-grid">
<router-link to="/achievements" class="quick-link-card">
<div class="quick-link-icon">🏆</div>
<h3>Наши достижения</h3>
<p>Узнайте о рекордах и победах участников клуба</p>
</router-link>
<router-link to="/gallery" class="quick-link-card">
<div class="quick-link-icon">📸</div>
<h3>Фотогалерея</h3>
<p>Яркие моменты тренировок и соревнований</p>
</router-link>
<router-link to="/members" class="quick-link-card">
<div class="quick-link-icon">👥</div>
<h3>Участники</h3>
<p>Познакомьтесь с нашей беговой семьей</p>
</router-link>
<router-link to="/training" class="quick-link-card">
<div class="quick-link-icon">📅</div>
<h3>Тренировки</h3>
<p>Расписание и программы занятий</p>
</router-link>
</div>
</div>
</section>
</div>
</template>
<script>
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Home',
methods: {
getImageUrl(path) {
// В продакшене замените на правильный путь
const baseUrl = import.meta.env.BASE_URL
// Путь от корня public/
console.log(`${baseUrl}images/${path}`)
return `${baseUrl}images/${path}`
},
}
}
</script>
<style scoped>
.home-page {
overflow-x: hidden;
}
/* Герой-секция */
.hero-section {
background: linear-gradient(135deg, rgba(46, 139, 86, 0.555) 0%, rgba(38, 115, 74, 0.477) 100%),
url('@/public/images/Roshim2025_3.png') center/cover no-repeat;
color: white;
padding: 120px 0 80px;
position: relative;
}
.hero-overlay {
position: relative;
z-index: 2;
}
.hero-content {
text-align: center;
max-width: 800px;
margin: 0 auto;
}
.hero-title {
margin-bottom: 1.5rem;
}
.title-main {
display: block;
font-size: 3rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 0.5rem;
}
.title-sub {
display: block;
font-size: 1.5rem;
font-weight: 300;
opacity: 0.9;
}
.hero-description {
font-size: 1.2rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.hero-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 3rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 15px 30px;
border-radius: 50px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
gap: 0.5rem;
}
.btn-large {
padding: 18px 35px;
font-size: 1.1rem;
}
.btn-primary {
background: #ffd700;
color: #333;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 215, 0, 0.4);
}
.btn-outline {
background: transparent;
color: white;
border-color: white;
}
.btn-outline:hover {
background: white;
color: #2e8b57;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
}
.hero-stats {
display: flex;
justify-content: center;
gap: 3rem;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-number {
font-size: 2.5rem;
font-weight: 800;
color: #ffd700;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.8;
}
/* Секции */
.section-title {
text-align: center;
font-size: 2.5rem;
color: #2e8b57;
margin-bottom: 3rem;
font-weight: 700;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Преимущества */
.benefits-section {
padding: 80px 0;
background: #f8fff8;
}
.benefits-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
}
.benefit-card {
background: white;
padding: 2.5rem 2rem;
border-radius: 15px;
text-align: center;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
}
.benefit-card:hover {
transform: translateY(-5px);
}
.benefit-icon {
font-size: 3rem;
margin-bottom: 1.5rem;
}
.benefit-card h3 {
color: #2e8b57;
margin-bottom: 1rem;
font-size: 1.3rem;
}
.benefit-card p {
color: #666;
line-height: 1.5;
}
/* События */
.events-section {
padding: 80px 0;
}
.events-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.event-card {
display: flex;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
}
.event-card:hover {
transform: translateY(-3px);
}
.event-date {
background: #2e8b57;
color: white;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 80px;
}
.date-day {
font-size: 1.8rem;
font-weight: 800;
line-height: 1;
}
.date-month {
font-size: 0.9rem;
opacity: 0.9;
}
.event-info {
padding: 1.5rem;
flex: 1;
}
.event-info h3 {
color: #2e8b57;
margin-bottom: 0.5rem;
}
.event-info p {
color: #666;
margin-bottom: 0.5rem;
}
.event-tag {
display: inline-block;
padding: 0.3rem 0.8rem;
border-radius: 15px;
font-size: 0.8rem;
font-weight: 600;
}
.event-tag.free {
background: #e8f5e8;
color: #2e8b57;
}
.event-tag.regular {
background: #e3f2fd;
color: #1976d2;
}
.event-tag.long {
background: #fff3e0;
color: #f57c00;
}
.events-actions {
text-align: center;
}
/* Истории успеха */
.success-section {
padding: 80px 0;
background: #f8fff8;
}
.success-stories {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
}
.story-card {
background: white;
padding: 2rem;
border-radius: 15px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.story-avatar {
width: 60px;
height: 60px;
background: #2e8b57;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
flex-shrink: 0;
}
.story-content h3 {
color: #2e8b57;
margin-bottom: 0.5rem;
}
.story-achievement {
color: #ffd700;
font-weight: 600;
margin-bottom: 1rem;
}
.story-text {
color: #666;
line-height: 1.5;
font-style: italic;
}
/* CTA секция */
.cta-section {
padding: 100px 0;
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
text-align: center;
}
.cta-content h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.cta-content>p {
font-size: 1.2rem;
margin-bottom: 2rem;
opacity: 0.9;
}
.cta-features {
display: inline-flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2.5rem;
text-align: left;
}
.cta-feature {
font-size: 1.1rem;
}
.cta-actions {
display: flex;
flex-direction: column;
gap: 2rem;
align-items: center;
}
.btn-cta {
padding: 20px 40px;
font-size: 1.2rem;
}
.cta-contacts p {
margin-bottom: 1rem;
opacity: 0.9;
}
.contact-links {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
justify-content: center;
}
.contact-link {
color: #ffd700;
text-decoration: none;
font-weight: 600;
transition: opacity 0.3s ease;
}
.contact-link:hover {
opacity: 0.8;
}
/* Быстрые ссылки */
.quick-links-section {
padding: 80px 0;
}
.quick-links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.quick-link-card {
background: white;
padding: 2.5rem 2rem;
border-radius: 15px;
text-align: center;
text-decoration: none;
color: inherit;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 2px solid transparent;
}
.quick-link-card:hover {
transform: translateY(-5px);
border-color: #2e8b57;
box-shadow: 0 10px 30px rgba(46, 139, 87, 0.15);
}
.quick-link-icon {
font-size: 3rem;
margin-bottom: 1.5rem;
}
.quick-link-card h3 {
color: #2e8b57;
margin-bottom: 1rem;
font-size: 1.3rem;
}
.quick-link-card p {
color: #666;
line-height: 1.5;
}
/* Адаптивность */
@media (max-width: 768px) {
.hero-section {
padding: 80px 0 60px;
}
.title-main {
font-size: 2.2rem;
}
.title-sub {
font-size: 1.2rem;
}
.hero-actions {
flex-direction: column;
align-items: center;
}
.btn-large {
width: 100%;
max-width: 300px;
}
.hero-stats {
gap: 2rem;
}
.stat-number {
font-size: 2rem;
}
.section-title {
font-size: 2rem;
}
.event-card {
flex-direction: column;
}
.event-date {
flex-direction: row;
gap: 1rem;
justify-content: center;
}
.story-card {
flex-direction: column;
text-align: center;
}
.contact-links {
flex-direction: column;
gap: 1rem;
}
}
@media (max-width: 480px) {
.title-main {
font-size: 1.8rem;
}
.benefits-grid,
.events-grid,
.success-stories,
.quick-links-grid {
grid-template-columns: 1fr;
}
.container {
padding: 0 15px;
}
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="page">
<h1>🔐 Вход в систему</h1>
<p>Войдите в свой личный кабинет</p>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<input type="email" placeholder="Email" class="form-input" v-model="credentials.email" required
:disabled="loading">
</div>
<div class="form-group">
<input type="password" placeholder="Пароль" class="form-input" v-model="credentials.password" required
:disabled="loading">
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Вход...' : 'Войти' }}
</button>
<div v-if="error" class="error-message">
{{ error }}
</div>
</form>
<div class="login-links">
<div class="register-link">
<p>Нет аккаунта? <router-link to="/register" class="link">Зарегистрируйтесь здесь</router-link></p>
</div>
<p><a href="#" class="link">Забыли пароль?</a></p>
</div>
<button class="btn btn-secondary" @click="$router.push('/')"> На главную</button>
</div>
</template>
<script>
import { useAuthStore } from '../stores/auth'
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Login',
setup() {
const authStore = useAuthStore()
return { authStore }
},
data() {
return {
credentials: {
email: '',
password: ''
}
}
},
computed: {
loading() {
return this.authStore.loading
},
error() {
return this.authStore.error
}
},
methods: {
async handleLogin() {
const result = await this.authStore.login(this.credentials)
if (result.success) {
// Показываем уведомление об успешном входе
this.showSuccessNotification()
// Редиректим после небольшой задержки
setTimeout(() => {
this.$router.push('/profile')
}, 1500)
}
},
showSuccessNotification() {
const notification = document.createElement('div')
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #2e8b57;
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
max-width: 300px;
font-family: Arial, sans-serif;
`
notification.textContent = '✅ Вход выполнен успешно!'
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 3000)
}
},
// Добавляем проверку при монтировании компонента
mounted() {
// Если пользователь уже авторизован, показываем уведомление
if (this.authStore.isAuthenticated) {
this.showAlreadyLoggedInNotification()
}
},
showAlreadyLoggedInNotification() {
const notification = document.createElement('div')
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #ffd700;
color: #333;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
max-width: 300px;
font-family: Arial, sans-serif;
`
notification.textContent = 'ℹ️ Вы уже авторизованы!'
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 3000)
}
}
</script>
<style scoped>
.login-form {
max-width: 300px;
margin: 2rem auto;
}
.form-group {
margin-bottom: 1rem;
}
.form-input {
width: 100%;
padding: 12px;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-input:focus {
outline: none;
border-color: #2e8b57;
}
.form-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.btn-primary {
width: 100%;
background-color: #2e8b57;
color: white;
padding: 12px;
font-size: 1rem;
margin-top: 1rem;
}
.btn-primary:hover:not(:disabled) {
background-color: #26734a;
}
.btn-primary:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.login-links {
margin-top: 1.5rem;
text-align: center;
}
.link {
color: #2e8b57;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px;
border-radius: 6px;
margin-top: 1rem;
border-left: 4px solid #c33;
text-align: center;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="page">
<h1>🚪 Выход из системы</h1>
<div class="logout-content">
<p>Выполняется выход из системы...</p>
<div class="loading-spinner"></div>
</div>
</div>
</template>
<script>
import { useAuthStore } from '../stores/auth'
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Logout',
setup() {
const authStore = useAuthStore()
return { authStore }
},
async mounted() {
// Выполняем выход
await this.authStore.logout()
// Редиректим на главную страницу
this.$router.push('/')
}
}
</script>
<style scoped>
.logout-content {
text-align: center;
padding: 2rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
<template>
<div class="page">
<div class="document-container">
<div class="document-header">
<h1>🔒 Политика конфиденциальности</h1>
<div class="document-meta">
<p>Дата последнего обновления: {{ lastUpdated }}</p>
<button class="btn btn-secondary" @click="downloadPDF">
📥 Скачать PDF
</button>
</div>
</div>
<div class="document-content">
<section class="document-section">
<h2>1. Общие положения</h2>
<p>1.1. Настоящая Политика конфиденциальности регулирует порядок сбора, хранения и использования персональных данных пользователей бегового клуба "Бегущий Башкир".</p>
<p>1.2. Используя наш сайт и услуги, вы соглашаетесь с условиями настоящей Политики.</p>
</section>
<section class="document-section">
<h2>2. Собираемая информация</h2>
<p>2.1. Мы собираем следующую информацию:</p>
<ul>
<li><strong>Персональные данные:</strong> имя, фамилия, email, телефон</li>
<li><strong>Данные для тренировок:</strong> уровень подготовки, цели, спортивные результаты</li>
<li><strong>Технические данные:</strong> IP-адрес, данные cookies, информация о браузере</li>
</ul>
</section>
<section class="document-section">
<h2>3. Цели использования данных</h2>
<p>3.1. Собранные данные используются для:</p>
<ul>
<li>Регистрации и идентификации пользователей</li>
<li>Предоставления персонализированных тренировочных программ</li>
<li>Организации мероприятий и забегов</li>
<li>Отправки информационных материалов (при согласии)</li>
<li>Улучшения качества наших услуг</li>
</ul>
</section>
<section class="document-section">
<h2>4. Защита данных</h2>
<p>4.1. Мы принимаем все необходимые меры для защиты ваших персональных данных от несанкционированного доступа.</p>
<p>4.2. Данные хранятся на защищенных серверах и передаются в зашифрованном виде.</p>
</section>
<section class="document-section">
<h2>5. Передача данных третьим лицам</h2>
<p>5.1. Мы не передаем ваши персональные данные третьим лицам, за исключением:</p>
<ul>
<li>Случаев, предусмотренных законодательством РФ</li>
<li>Партнеров по организации мероприятий (только с вашего согласия)</li>
<li>Сервисных провайдеров, обеспечивающих работу нашего сайта</li>
</ul>
</section>
<section class="document-section">
<h2>6. Cookies и аналитика</h2>
<p>6.1. Мы используем cookies для улучшения работы сайта и сбора аналитической информации.</p>
<p>6.2. Вы можете отключить cookies в настройках браузера.</p>
</section>
<section class="document-section">
<h2>7. Ваши права</h2>
<p>7.1. Вы имеете право:</p>
<ul>
<li>На доступ к вашим персональным данным</li>
<li>На исправление неточных данных</li>
<li>На удаление ваших данных</li>
<li>На отзыв согласия на обработку данных</li>
</ul>
</section>
<section class="document-section">
<h2>8. Контакты</h2>
<p>По вопросам, связанным с обработкой персональных данных, обращайтесь:</p>
<p>📧 Email: privacy@begushiybashkir.ru<br>
📞 Телефон: +7 (XXX) XXX-XX-XX</p>
</section>
</div>
<div class="document-actions">
<button class="btn btn-primary" @click="$router.back()">
Назад к регистрации
</button>
<button class="btn btn-secondary" @click="downloadPDF">
📥 Скачать политику
</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PrivacyPolicy',
data() {
return {
lastUpdated: '10 октября 2024 года'
}
},
methods: {
downloadPDF() {
const link = document.createElement('a')
link.href = '/documents/privacy-policy.pdf'
link.download = 'politika-konfidencialnosti.pdf'
link.click()
}
}
}
</script>
<style scoped>
/* Стили такие же как в TermsOfService.vue */
.document-container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
overflow: hidden;
}
.document-header {
background: linear-gradient(135deg, #2e8b57, #3da56a);
color: white;
padding: 2rem;
text-align: center;
}
.document-header h1 {
margin: 0 0 1rem 0;
font-size: 1.8rem;
}
.document-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.document-content {
padding: 2rem;
line-height: 1.6;
}
.document-section {
margin-bottom: 2rem;
}
.document-section h2 {
color: #2e8b57;
border-bottom: 2px solid #e8f5e8;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.document-section ul {
padding-left: 1.5rem;
}
.document-section li {
margin-bottom: 0.5rem;
}
.document-actions {
padding: 1.5rem 2rem;
background: #f8fff8;
border-top: 1px solid #e8f5e8;
display: flex;
gap: 1rem;
justify-content: center;
}
@media (max-width: 768px) {
.document-meta {
flex-direction: column;
text-align: center;
}
.document-content {
padding: 1.5rem;
}
.document-actions {
flex-direction: column;
}
}
</style>
+618
View File
@@ -0,0 +1,618 @@
<template>
<div class="page">
<h1>👤 Личный кабинет</h1>
<div v-if="authLoading" class="loading">Загрузка профиля...</div>
<div v-else-if="user" class="profile-content">
<div class="profile-header">
<!-- Обновленная секция аватара -->
<div class="avatar-section">
<div class="avatar-preview">
<img v-if="user.avatar" :src="avatarUrl" :alt="`Аватар ${user.firstName} ${user.lastName}`"
class="avatar-image" @error="handleAvatarError">
<div v-else class="avatar-placeholder">
👤
</div>
</div>
<AvatarUpload :user="user" @avatar-updated="onAvatarUpdated" />
</div>
<h2>{{ user.firstName }} {{ user.lastName }}</h2>
<p>Участник с {{ joinDate }}</p>
<p class="user-email">{{ user.email }}</p>
<p v-if="user.phone" class="user-phone">📱 {{ user.phone }}</p>
</div>
<!-- Остальной код остается без изменений -->
<div class="profile-info">
<h3>📋 Информация о пользователе</h3>
<div class="info-grid">
<div class="info-item">
<label>Уровень подготовки:</label>
<span class="info-value">{{ experienceLabel }}</span>
</div>
<div class="info-item">
<label>Цели:</label>
<span class="info-value">{{ goalsLabel }}</span>
</div>
<div class="info-item">
<label>Рассылка:</label>
<span class="info-value">{{ user.newsletter ? '✅ Подключена' : '❌ Отключена' }}</span>
</div>
<div class="info-item">
<label>Роль:</label>
<span class="info-value role-badge">{{ user.role }}</span>
</div>
</div>
</div>
<div class="profile-stats">
<div class="stats-header">
<h3>📊 Моя статистика</h3>
<button class="btn-refresh" @click="refreshStats" :disabled="statsLoading">
{{ statsLoading ? '⟳' : '🔄' }}
</button>
</div>
<div v-if="statsError" class="error-message">
{{ statsError }}
</div>
<div v-else class="stats-grid">
<div class="stat-card">
<h4>🏃 Всего пробег</h4>
<p>{{ userStats?.totalDistance || 0 }} км</p>
</div>
<div class="stat-card">
<h4> Лучший результат</h4>
<p>{{ userStats?.bestResult || 'Нет данных' }}</p>
</div>
<div class="stat-card">
<h4>📅 Тренировок</h4>
<p>{{ userStats?.totalWorkouts || 0 }}</p>
</div>
<div class="stat-card">
<h4>🔥 Сожжено калорий</h4>
<p>{{ userStats?.caloriesBurned || 0 }}</p>
</div>
</div>
</div>
<div class="achievements-preview">
<h3>🏆 Достижения</h3>
<div class="achievements-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: achievementProgress + '%' }"></div>
</div>
<span>{{ achievementProgress }}% выполнено</span>
</div>
<div class="achievements-count">
<span>Получено: {{ completedAchievements.length }} из {{ userAchievements.length }}</span>
</div>
<button class="btn btn-outline" @click="$router.push('/achievements')">
📜 Все достижения
</button>
</div>
<div class="profile-actions">
<button class="btn" @click="editProfile"> Редактировать профиль</button>
<button class="btn" @click="viewDetailedStats">📊 Подробная статистика</button>
<button class="btn" @click="$router.push('/training')">📅 Мой план тренировок</button>
<button class="btn btn-logout" @click="handleLogout" :disabled="authLoading">
{{ authLoading ? 'Выход...' : '🚪 Выйти' }}
</button>
</div>
</div>
<div v-else class="error-message">
Не удалось загрузить данные профиля.
<router-link to="/login" class="link">Войдите</router-link> снова.
</div>
<button class="btn btn-secondary" @click="$router.push('/')"> На главную</button>
</div>
</template>
<script>
import { useAuthStore } from '../stores/auth'
import { useUserStore } from '../stores/user'
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Profile',
setup() {
const authStore = useAuthStore()
const userStore = useUserStore()
return { authStore, userStore }
},
data() {
return {
authLoading: false,
statsLoading: false,
avatarLoadError: false
}
},
computed: {
user() {
return this.authStore.user
},
userStats() {
return this.userStore.userStats
},
userAchievements() {
return this.userStore.userAchievements
},
completedAchievements() {
return this.userStore.completedAchievements
},
achievementProgress() {
return this.userStore.achievementProgress
},
statsError() {
return this.userStore.error
},
// Вычисляем полный URL аватара
avatarUrl() {
if (!this.user?.avatar) return null;
let filename = this.user.avatar.trim('/').split('/').pop();
// Иначе формируем полный URL
const baseUrl = 'https://begushiybashkir.ru/api/v1/user/avatars/';
return baseUrl + filename;
},
joinDate() {
if (!this.user?.createdAt) return 'января 2024';
const date = new Date(this.user.createdAt);
const month = date.toLocaleString('ru-RU', { month: 'long' });
const year = date.getFullYear();
return `${month} ${year}`;
},
experienceLabel() {
const experienceMap = {
'beginner': 'Начинающий (0-6 месяцев)',
'intermediate': 'Любитель (6-24 месяцев)',
'advanced': 'Опытный (2+ лет)',
'professional': 'Профессионал'
};
return experienceMap[this.user?.experience] || 'Не указан';
},
goalsLabel() {
const goalsMap = {
'health': 'Улучшить здоровье',
'weight': 'Сбросить вес',
'first5k': 'Пробежать первые 5 км',
'first10k': 'Пробежать первые 10 км',
'halfMarathon': 'Подготовиться к полумарафону',
'marathon': 'Подготовиться к марафону',
'improve': 'Улучшить результаты',
'social': 'Общение и компания'
};
return goalsMap[this.user?.goals] || 'Не указана';
}
},
methods: {
async onAvatarUpdated() {
// Сбрасываем флаг ошибки при обновлении аватара
this.avatarLoadError = false;
// Принудительно обновляем профиль
await this.authStore.fetchProfile();
console.log('Avatar updated, user data:', this.authStore.user);
},
// Обработчик ошибки загрузки изображения
handleAvatarError() {
console.error('Ошибка загрузки аватара:', this.avatarUrl);
this.avatarLoadError = true;
},
async loadUserData() {
this.authLoading = true;
this.avatarLoadError = false;
try {
await this.authStore.fetchProfile();
await this.loadStats();
} catch (error) {
console.error('Ошибка загрузки данных:', error);
} finally {
this.authLoading = false;
}
},
async loadStats() {
this.statsLoading = true;
try {
const [statsResult, achievementsResult] = await Promise.all([
this.userStore.fetchUserStats(),
this.userStore.fetchUserAchievements()
]);
if (!statsResult.success) {
console.error('Ошибка загрузки статистики:', statsResult.error);
}
if (!achievementsResult.success) {
console.error('Ошибка загрузки достижений:', achievementsResult.error);
}
} catch (error) {
console.error('Ошибка загрузки статистики:', error);
} finally {
this.statsLoading = false;
}
},
async refreshStats() {
await this.loadStats();
},
async handleLogout() {
await this.authStore.logout();
this.$router.push('/');
},
editProfile() {
this.$router.push('/profile/edit');
},
viewDetailedStats() {
// TODO: Переход на страницу детальной статистики
alert('Функция в разработке');
}
},
async mounted() {
if (!this.user) {
await this.loadUserData();
} else {
await this.loadStats();
}
}
}
</script>
<style scoped>
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.profile-content {
margin-top: 2rem;
}
.profile-header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: white;
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.avatar-section {
margin-bottom: 1.5rem;
}
.avatar-preview {
width: 150px;
height: 150px;
margin: 0 auto 1rem;
border-radius: 50%;
overflow: hidden;
border: 4px solid #2e8b57;
background: linear-gradient(135deg, #f5f5f5, #e0e0e0);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.avatar-image:hover {
transform: scale(1.05);
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
background: linear-gradient(135deg, #2e8b57, #3cb371);
color: white;
}
.profile-header h2 {
margin: 1rem 0 0.5rem;
color: #333;
font-size: 1.8rem;
}
.user-email,
.user-phone {
color: #666;
margin: 0.25rem 0;
}
.profile-info,
.profile-stats,
.achievements-preview {
background: white;
padding: 1.5rem;
border-radius: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #2e8b57;
}
.info-item label {
font-weight: 600;
color: #555;
}
.info-value {
color: #333;
font-weight: 500;
}
.role-badge {
background: #2e8b57;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.btn-refresh {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: all 0.3s ease;
background-color: #f8f9fa;
}
.btn-refresh:hover:not(:disabled) {
background-color: #2e8b57;
color: white;
transform: rotate(90deg);
}
.btn-refresh:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.stat-card {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
padding: 1.5rem 1rem;
border-radius: 12px;
text-align: center;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
border-color: #2e8b57;
}
.stat-card h4 {
margin: 0 0 0.5rem;
color: #555;
font-size: 0.9rem;
}
.stat-card p {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
color: #2e8b57;
}
.achievements-preview {
text-align: center;
}
.achievements-progress {
display: flex;
align-items: center;
gap: 1rem;
margin: 1rem 0;
justify-content: center;
}
.progress-bar {
flex: 1;
max-width: 300px;
height: 12px;
background-color: #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #2e8b57, #3cb371);
transition: width 0.5s ease;
}
.achievements-count {
margin: 1rem 0;
color: #666;
font-weight: 500;
}
.profile-actions {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 300px;
margin: 2rem auto;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
background: #2e8b57;
color: white;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn:hover:not(:disabled) {
background: #26734d;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(46, 139, 87, 0.3);
}
.btn:disabled {
background-color: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-outline {
background: white;
color: #2e8b57;
border: 2px solid #2e8b57;
}
.btn-outline:hover {
background: #2e8b57;
color: white;
}
.btn-logout {
background-color: #dc3545;
margin-top: 1rem;
}
.btn-logout:hover:not(:disabled) {
background-color: #c82333;
}
.btn-secondary {
background-color: #6c757d;
margin-top: 2rem;
}
.btn-secondary:hover {
background-color: #545b62;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 2rem;
border-radius: 8px;
text-align: center;
margin: 2rem 0;
border-left: 4px solid #c33;
}
.loading {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
color: #666;
}
.link {
color: #2e8b57;
text-decoration: none;
font-weight: 600;
}
.link:hover {
text-decoration: underline;
}
/* Адаптивность */
@media (max-width: 768px) {
.page {
padding: 1rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.profile-actions {
max-width: 100%;
}
.achievements-progress {
flex-direction: column;
gap: 0.5rem;
}
.avatar-preview {
width: 120px;
height: 120px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.profile-header {
padding: 1.5rem;
}
.profile-header h2 {
font-size: 1.5rem;
}
}
</style>
+374
View File
@@ -0,0 +1,374 @@
<template>
<div class="page">
<h1> Редактирование профиля</h1>
<div class="avatar-section">
<h3>Фотография профиля</h3>
<AvatarUpload :user="user" :show-actions="true" @avatar-updated="onAvatarUpdated" />
</div>
<div v-if="loading && !user" class="loading">Загрузка...</div>
<form v-else @submit.prevent="handleSubmit" class="profile-edit-form">
<div class="form-row">
<div class="form-group">
<label for="firstName">Имя *</label>
<input id="firstName" v-model="formData.firstName" type="text" class="form-input"
placeholder="Введите ваше имя" required :disabled="loading">
</div>
<div class="form-group">
<label for="lastName">Фамилия *</label>
<input id="lastName" v-model="formData.lastName" type="text" class="form-input"
placeholder="Введите вашу фамилию" required :disabled="loading">
</div>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input id="email" v-model="formData.email" type="email" class="form-input" placeholder="example@mail.ru"
required readonly disabled style="background-color: #f5f5f5; cursor: not-allowed;">
<small style="color: #666; font-size: 0.9rem;">
Email нельзя изменить. Для смены email обратитесь в поддержку.
</small>
</div>
<div class="form-group">
<label for="experience">Уровень подготовки</label>
<select id="experience" v-model="formData.experience" class="form-input" :disabled="loading">
<option value="">Выберите уровень</option>
<option value="beginner">Начинающий (0-6 месяцев)</option>
<option value="intermediate">Любитель (6-24 месяцев)</option>
<option value="advanced">Опытный (2+ лет)</option>
<option value="professional">Профессионал</option>
</select>
</div>
<div class="form-group">
<label for="goals">Цели</label>
<select id="goals" v-model="formData.goals" class="form-input" :disabled="loading">
<option value="">Выберите цель</option>
<option value="health">Улучшить здоровье</option>
<option value="weight">Сбросить вес</option>
<option value="first5k">Пробежать первые 5 км</option>
<option value="first10k">Пробежать первые 10 км</option>
<option value="halfMarathon">Подготовиться к полумарафону</option>
<option value="marathon">Подготовиться к марафону</option>
<option value="improve">Улучшить результаты</option>
<option value="social">Общение и компания</option>
</select>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input v-model="formData.newsletter" type="checkbox" class="checkbox" :disabled="loading">
<span class="checkmark"></span>
Хочу получать новости о тренировках и мероприятиях
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="loading || !isFormChanged">
{{ loading ? 'Сохранение...' : '💾 Сохранить изменения' }}
</button>
<button type="button" class="btn btn-secondary" @click="cancelEdit" :disabled="loading">
Отмена
</button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="success" class="success-message">
Профиль успешно обновлен!
</div>
</form>
<div class="navigation-actions">
<button class="btn btn-secondary" @click="$router.push('/profile')"> Назад к профилю</button>
<button class="btn btn-secondary" @click="$router.push('/')">🏠 На главную</button>
</div>
</div>
</template>
<script>
import { useAuthStore } from '../stores/auth'
import AvatarUpload from '../components/AvatarUpload.vue'
export default {
name: 'ProfileEdit',
components: {
AvatarUpload
},
setup() {
const authStore = useAuthStore()
return { authStore }
},
data() {
return {
formData: {
firstName: '',
lastName: '',
email: '', // Только для отображения, не для изменения
phone: '',
experience: '',
goals: '',
newsletter: false
},
originalData: {},
loading: false,
error: '',
success: false
}
},
computed: {
user() {
return this.authStore.user
},
isFormChanged() {
// Исключаем email из сравнения, так как он не изменяется
const formDataCopy = { ...this.formData }
const originalDataCopy = { ...this.originalData }
delete formDataCopy.email
delete originalDataCopy.email
return JSON.stringify(formDataCopy) !== JSON.stringify(originalDataCopy)
}
},
methods: {
async onAvatarUpdated() {
// Обновляем данные пользователя после загрузки аватара
await this.authStore.fetchProfile()
this.initializeForm()
},
initializeForm() {
if (this.user) {
this.formData = {
firstName: this.user.firstName || '',
lastName: this.user.lastName || '',
email: this.user.email || '', // Только для отображения
phone: this.user.phone || '',
experience: this.user.experience || '',
goals: this.user.goals || '',
newsletter: this.user.newsletter || false
}
this.originalData = { ...this.formData }
}
},
async handleSubmit() {
this.loading = true
this.error = ''
this.success = false
try {
// Используем метод updateProfile из authStore
const result = await this.authStore.updateProfile({
firstName: this.formData.firstName,
lastName: this.formData.lastName,
phone: this.formData.phone,
experience: this.formData.experience,
goals: this.formData.goals,
newsletter: this.formData.newsletter
})
if (result.success) {
this.originalData = { ...this.formData }
this.success = true
// Принудительно обновляем профиль в сторе
await this.authStore.fetchProfile()
} else {
this.error = result.error
}
} catch (err) {
this.error = err.response?.data?.message || 'Ошибка обновления профиля'
} finally {
this.loading = false
}
},
cancelEdit() {
this.initializeForm()
this.error = ''
this.success = false
}
},
mounted() {
if (this.user) {
this.initializeForm()
} else {
// Если пользователь не загружен, загружаем данные
this.loading = true
this.authStore.fetchProfile().finally(() => {
this.loading = false
this.initializeForm()
})
}
}
}
</script>
<style scoped>
.profile-edit-form {
max-width: 500px;
margin: 2rem auto;
background: white;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.form-input {
width: 100%;
padding: 12px;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-input:focus {
outline: none;
border-color: #2e8b57;
}
.form-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.checkbox-group {
margin: 1.5rem 0;
}
.checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
font-weight: normal;
}
.checkbox {
margin-right: 10px;
margin-top: 3px;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid #ddd;
border-radius: 3px;
margin-right: 10px;
margin-top: 2px;
position: relative;
flex-shrink: 0;
}
.checkbox:checked+.checkmark {
background-color: #2e8b57;
border-color: #2e8b57;
}
.checkbox:checked+.checkmark::after {
content: '✓';
color: white;
position: absolute;
top: -2px;
left: 2px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.btn-primary {
flex: 1;
background-color: #2e8b57;
color: white;
padding: 12px;
}
.btn-primary:hover:not(:disabled) {
background-color: #26734a;
}
.btn-primary:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.success-message {
background-color: #efe;
color: #2e8b57;
padding: 12px;
border-radius: 6px;
margin-top: 1rem;
border-left: 4px solid #2e8b57;
text-align: center;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px;
border-radius: 6px;
margin-top: 1rem;
border-left: 4px solid #c33;
}
.navigation-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
}
.loading {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
color: #666;
}
/* Адаптивность */
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
.profile-edit-form {
padding: 1.5rem;
margin: 1rem;
}
.form-actions {
flex-direction: column;
}
.navigation-actions {
flex-direction: column;
align-items: center;
}
}
</style>
+370
View File
@@ -0,0 +1,370 @@
<template>
<div class="page">
<div class="register-container">
<h1>👤 Регистрация</h1>
<p>Присоединяйтесь к нашему беговому сообществу</p>
<form @submit.prevent="handleRegister" class="register-form">
<div class="form-row">
<div class="form-group">
<label for="firstName">Имя *</label>
<input id="firstName" v-model="formData.firstName" type="text" class="form-input"
placeholder="Введите ваше имя" required :disabled="loading">
</div>
<div class="form-group">
<label for="lastName">Фамилия *</label>
<input id="lastName" v-model="formData.lastName" type="text" class="form-input"
placeholder="Введите вашу фамилию" required :disabled="loading">
</div>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input id="email" v-model="formData.email" type="email" class="form-input" placeholder="example@mail.ru"
required :disabled="loading">
</div>
<div class="form-group">
<label for="phone">Телефон</label>
<input id="phone" v-model="formData.phone" type="tel" class="form-input" placeholder="+7 (999) 123-45-67"
:disabled="loading">
</div>
<div class="form-row">
<div class="form-group">
<label for="password">Пароль *</label>
<input id="password" v-model="formData.password" type="password" class="form-input"
placeholder="Не менее 6 символов" required minlength="6" :disabled="loading">
</div>
<div class="form-group">
<label for="confirmPassword">Подтверждение пароля *</label>
<input id="confirmPassword" v-model="formData.confirmPassword" type="password" class="form-input"
placeholder="Повторите пароль" required :disabled="loading">
</div>
</div>
<div class="form-group">
<label for="experience">Уровень подготовки</label>
<select id="experience" v-model="formData.experience" class="form-input" :disabled="loading">
<option value="">Выберите уровень</option>
<option value="beginner">Начинающий (0-6 месяцев)</option>
<option value="intermediate">Любитель (6-24 месяцев)</option>
<option value="advanced">Опытный (2+ лет)</option>
<option value="professional">Профессионал</option>
</select>
</div>
<div class="form-group">
<label for="goals">Цели</label>
<select id="goals" v-model="formData.goals" class="form-input" :disabled="loading">
<option value="">Выберите цель</option>
<option value="health">Улучшить здоровье</option>
<option value="weight">Сбросить вес</option>
<option value="first5k">Пробежать первые 5 км</option>
<option value="first10k">Пробежать первые 10 км</option>
<option value="halfMarathon">Подготовиться к полумарафону</option>
<option value="marathon">Подготовиться к марафону</option>
<option value="improve">Улучшить результаты</option>
<option value="social">Общение и компания</option>
</select>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input v-model="formData.agreeTerms" type="checkbox" class="checkbox" required :disabled="loading">
<span class="checkmark"></span>
Я соглашаюсь с
<router-link to="/terms" class="link" target="_blank">правилами клуба</router-link> и
<router-link to="/privacy" class="link" target="_blank">политикой конфиденциальности</router-link> *
</label>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input v-model="formData.newsletter" type="checkbox" class="checkbox" :disabled="loading">
<span class="checkmark"></span>
Хочу получать новости о тренировках и мероприятиях
</label>
</div>
<button type="submit" class="btn btn-primary" :disabled="!formData.agreeTerms || loading">
{{ loading ? 'Регистрация...' : '🏃 Зарегистрироваться' }}
</button>
<div v-if="error" class="error-message">
{{ error }}
</div>
</form>
<div class="login-link">
<p>Уже есть аккаунт? <router-link to="/login" class="link">Войдите здесь</router-link></p>
</div>
<div class="benefits">
<h3>Что вы получите после регистрации:</h3>
<ul class="benefits-list">
<li> Доступ к расписанию тренировок</li>
<li> Персональный трекер прогресса</li>
<li> Общение с тренером и участниками</li>
<li> Участие в клубных мероприятиях</li>
<li> Скидки на стартовые взносы</li>
</ul>
</div>
</div>
<button class="btn btn-secondary" @click="$router.push('/')"> На главную</button>
</div>
</template>
<script>
import { useAuthStore } from '../stores/auth'
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Register',
setup() {
const authStore = useAuthStore()
return { authStore }
},
data() {
return {
formData: {
firstName: '',
lastName: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
experience: '',
goals: '',
agreeTerms: false,
newsletter: true
},
showDebugInfo: import.meta.env.DEV // Показывать отладочную информацию только в development
}
},
computed: {
loading() {
return this.authStore.loading
},
error() {
return this.authStore.error
}
},
methods: {
async handleRegister() {
// Валидация
if (this.formData.password !== this.formData.confirmPassword) {
this.authStore.error = 'Пароли не совпадают'
return
}
if (this.formData.password.length < 6) {
this.authStore.error = 'Пароль должен содержать не менее 6 символов'
return
}
if (!this.formData.agreeTerms) {
this.authStore.error = 'Необходимо согласие с правилами клуба'
return
}
// Подготовка данных для API
const registerData = {
email: this.formData.email,
password: this.formData.password,
firstName: this.formData.firstName,
lastName: this.formData.lastName,
phone: this.formData.phone,
experience: this.formData.experience,
goals: this.formData.goals,
newsletter: this.formData.newsletter
}
console.log('Отправка данных регистрации:', { ...registerData, password: '***' })
alert("|" + registerData.email + "|" + registerData.password + "|")
const result = await this.authStore.register(registerData)
alert("register seccess=" + result.success + "| data=" + result.data)
if (result.success) {
// Перенаправляем на страницу профиля после успешной регистрации
this.$router.push('/profile')
} else {
console.error('Ошибка регистрации:', result.error)
}
}
}
}
</script>
<style scoped>
/* Стили остаются без изменений */
.register-container {
max-width: 500px;
margin: 0 auto;
text-align: left;
}
.register-form {
background: white;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1);
margin: 2rem 0;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.form-input {
width: 100%;
padding: 12px;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-input:focus {
outline: none;
border-color: #2e8b57;
}
.form-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.checkbox-group {
margin: 1.5rem 0;
}
.checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
font-weight: normal;
}
.checkbox {
margin-right: 10px;
margin-top: 3px;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid #ddd;
border-radius: 3px;
margin-right: 10px;
margin-top: 2px;
position: relative;
flex-shrink: 0;
}
.checkbox:checked+.checkmark {
background-color: #2e8b57;
border-color: #2e8b57;
}
.checkbox:checked+.checkmark::after {
content: '✓';
color: white;
position: absolute;
top: -2px;
left: 2px;
font-size: 14px;
}
.btn-primary {
width: 100%;
background-color: #2e8b57;
color: white;
padding: 15px;
font-size: 1.1rem;
margin-top: 1rem;
}
.btn-primary:hover:not(:disabled) {
background-color: #26734a;
}
.btn-primary:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px;
border-radius: 6px;
margin-top: 1rem;
border-left: 4px solid #c33;
}
.login-link {
text-align: center;
margin: 1.5rem 0;
}
.link {
color: #2e8b57;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.benefits {
background-color: #f8fff8;
padding: 1.5rem;
border-radius: 8px;
border-left: 4px solid #2e8b57;
margin: 2rem 0;
}
.benefits h3 {
color: #2e8b57;
margin-bottom: 1rem;
}
.benefits-list {
list-style: none;
padding: 0;
}
.benefits-list li {
padding: 0.3rem 0;
color: #555;
}
/* Адаптивность */
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
.register-form {
padding: 1.5rem;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
<template>
<div class="page">
<div class="document-container">
<div class="document-header">
<h1>📋 Правила бегового клуба "Бегущий Башкир"</h1>
<div class="document-meta">
<p>Дата последнего обновления: {{ lastUpdated }}</p>
<button class="btn btn-secondary" @click="downloadPDF">
📥 Скачать PDF
</button>
</div>
</div>
<div class="document-content">
<section class="document-section">
<h2>1. Общие положения</h2>
<p>1.1. Беговой клуб "Бегущий Башкир" (далее "Клуб") это сообщество любителей бега, созданное для популяризации здорового образа жизни и развития беговой культуры в Республике Башкортостан.</p>
<p>1.2. Участником Клуба может стать любой желающий, достигший 18 лет и согласившийся с настоящими Правилами.</p>
</section>
<section class="document-section">
<h2>2. Членство в Клубе</h2>
<p>2.1. Для вступления в Клуб необходимо:</p>
<ul>
<li>Заполнить регистрационную форму на сайте</li>
<li>Ознакомиться и принять настоящие Правила</li>
<li>Оплатить членский взнос (при наличии)</li>
</ul>
<p>2.2. Участники Клуба имеют право:</p>
<ul>
<li>Участвовать в регулярных тренировках Клуба</li>
<li>Получать консультации тренеров</li>
<li>Участвовать в клубных мероприятиях и забегах</li>
<li>Получать скидки от партнеров Клуба</li>
</ul>
</section>
<section class="document-section">
<h2>3. Обязанности участников</h2>
<p>3.1. Участники обязаны:</p>
<ul>
<li>Соблюдать технику безопасности во время тренировок</li>
<li>Быть пунктуальными</li>
<li>Уважительно относиться к другим участникам и тренерам</li>
<li>Следовать указаниям тренера</li>
<li>Сообщать тренеру о проблемах со здоровьем</li>
</ul>
</section>
<section class="document-section">
<h2>4. Тренировочный процесс</h2>
<p>4.1. Расписание тренировок публикуется на сайте Клуба и в официальных группах в социальных сетях.</p>
<p>4.2. Участники обязаны предупреждать тренера о невозможности посетить тренировку.</p>
</section>
<section class="document-section">
<h2>5. Безопасность</h2>
<p>5.1. Участники несут ответственность за свое здоровье и безопасность во время тренировок.</p>
<p>5.2. Клуб не несет ответственности за травмы, полученные в результате несоблюдения техники безопасности.</p>
</section>
<section class="document-section">
<h2>6. Конфиденциальность</h2>
<p>6.1. Клуб обязуется не передавать персональные данные участников третьим лицам.</p>
</section>
</div>
<div class="document-actions">
<button class="btn btn-primary" @click="$router.back()">
Назад к регистрации
</button>
<button class="btn btn-secondary" @click="downloadPDF">
📥 Скачать правила
</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'TermsOfService',
data() {
return {
lastUpdated: '10 октября 2024 года'
}
},
methods: {
downloadPDF() {
// Временная реализация - можно заменить на реальный PDF
const link = document.createElement('a')
link.href = '/documents/terms-of-service.pdf'
link.download = 'pravila-begovogo-kluba.pdf'
link.click()
}
}
}
</script>
<style scoped>
.document-container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
overflow: hidden;
}
.document-header {
background: linear-gradient(135deg, #2e8b57, #3da56a);
color: white;
padding: 2rem;
text-align: center;
}
.document-header h1 {
margin: 0 0 1rem 0;
font-size: 1.8rem;
}
.document-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.document-content {
padding: 2rem;
line-height: 1.6;
}
.document-section {
margin-bottom: 2rem;
}
.document-section h2 {
color: #2e8b57;
border-bottom: 2px solid #e8f5e8;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.document-section ul {
padding-left: 1.5rem;
}
.document-section li {
margin-bottom: 0.5rem;
}
.document-actions {
padding: 1.5rem 2rem;
background: #f8fff8;
border-top: 1px solid #e8f5e8;
display: flex;
gap: 1rem;
justify-content: center;
}
/* Адаптивность */
@media (max-width: 768px) {
.document-meta {
flex-direction: column;
text-align: center;
}
.document-content {
padding: 1.5rem;
}
.document-actions {
flex-direction: column;
}
}
</style>
File diff suppressed because it is too large Load Diff