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
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+2
View File
@@ -0,0 +1,2 @@
VITE_APP_DEBUG=true
VITE_API_BASE_URL=https://begushiybashkir.ru/api/v1
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+44
View File
@@ -0,0 +1,44 @@
# bbvue
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
])
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="src/assets/logo/Logo.png" />
<title>Бегущий Башкир | Беговой клуб в Уфе</title>
<meta
name="description"
content="Беговой клуб 'Бегущий Башкир' в Уфе: тренировки на свежем воздухе, профессиональный тренер, участие в марафонах. Запишитесь на занятия!"
/>
<meta
name="keywords"
content="беговой клуб,
беговая школа Уфы,
беговая академия Башкортостана,
тренировки на свежем воздухе,
марафон Уфа,
полумарафон,
трейловый бег,
Аминев Загир,
Мастер спорта по полиатлону,
КМС по скайраннингу,
беговые достижения,
беговая команда,
спорт в Уфе,
здоровый образ жизни,
беговые тренировки,
беговые клубы Башкортостана,
бег в Уфе,
беговой клуб Уфа,
ультрамарафон"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+4492
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "bbvue",
"version": "0.0.13",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"axios": "^1.12.2",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"eslint": "^9.33.0",
"eslint-plugin-vue": "~10.4.0",
"globals": "^16.3.0",
"prettier": "3.6.2",
"vite": "^7.1.7",
"vite-plugin-vue-devtools": "^8.0.2"
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+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
+22
View File
@@ -0,0 +1,22 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./', import.meta.url))
},
},
server: {
port: 3001,
host: true
}
})
+1 -1
View File
@@ -33,7 +33,7 @@ services:
- ../yalarba/serv_spa/spa/vue/dist:/usr/share/nginx/yalarba/html - ../yalarba/serv_spa/spa/vue/dist:/usr/share/nginx/yalarba/html
- ../valitovgaziz/html:/usr/share/nginx/valitovgaziz/html - ../valitovgaziz/html:/usr/share/nginx/valitovgaziz/html
- ../yalarba/easySite/easy-site/prod:/usr/share/nginx/easysite102/html - ../yalarba/easySite/easy-site/prod:/usr/share/nginx/easysite102/html
- ../begushiybashkir/bbvue/dist:/usr/share/nginx/begushiybashkir/html - ./begushiybashkir/bbvue/dist:/usr/share/nginx/begushiybashkir/html
networks: networks:
- web-network - web-network
- internal - internal