Files
tp/serv_nginx/bbvue/src/views/Profile.vue
T
valitovgaziz 88046f722d modified: serv_nginx/bbvue/src/stores/user.js
modified:   serv_nginx/bbvue/src/views/Profile.vue
change GET path for personal-bests
2025-10-20 03:32:41 +05:00

1052 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page">
<h2>👤 Бегущий башкир: </h2>
<h1>{{ user.firstName }} {{ user.lastName }}</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 && !avatarLoadError" :src="avatarUrl"
:alt="`Аватар ${user.firstName} ${user.lastName}`" class="avatar-image" @error="handleAvatarError">
<div v-else class="avatar-placeholder">
👤
</div>
</div>
</div>
<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>{{ formatTime(userStats?.totalTime) }}</p>
</div>
<div class="stat-card">
<h4>📅 Тренировок</h4>
<p>{{ userStats?.workoutsCount || 0 }}</p>
</div>
<div class="stat-card">
<h4>🔥 Текущая серия</h4>
<p>{{ userStats?.currentStreak || 0 }} дней</p>
</div>
<div class="stat-card">
<h4> Лучшая серия</h4>
<p>{{ userStats?.longestStreak || 0 }} дней</p>
</div>
<div class="stat-card">
<h4>📈 Пробег за неделю</h4>
<p>{{ userStats?.weeklyDistance || 0 }} км</p>
</div>
</div>
</div>
<!-- Личные рекорды -->
<div class="personal-bests-section" v-if="personalBests.length > 0">
<h3> Личные рекорды</h3>
<div class="bests-grid">
<div v-for="best in personalBests" :key="best.id" class="best-card">
<div class="best-type">{{ getDistanceLabel(best.distanceType) }}</div>
<div class="best-time">{{ best.time }}</div>
<div class="best-date">{{ formatDate(best.date) }}</div>
<div v-if="best.eventName" class="best-event">{{ best.eventName }}</div>
<span v-if="best.verified" class="verified-badge"> Проверен</span>
</div>
</div>
</div>
<!-- Ближайшие события -->
<div class="events-section" v-if="upcomingEvents.length > 0">
<h3>📅 Ближайшие события</h3>
<div class="events-list">
<div v-for="event in upcomingEvents" :key="event.id" class="event-card">
<div class="event-date">{{ formatEventDate(event.date) }}</div>
<div class="event-title">{{ event.title }}</div>
<div class="event-location">📍 {{ event.location }}</div>
<div class="event-distance" v-if="event.distance">{{ event.distance }}</div>
<span class="event-type" :class="event.type">{{ getEventTypeLabel(event.type) }}</span>
</div>
</div>
<button class="btn btn-outline" @click="$router.push('/events')">
📋 Все события
</button>
</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>
<div v-if="recentAchievements.length > 0" class="recent-achievements">
<h4>Последние достижения:</h4>
<div class="achievements-list">
<div v-for="achievement in recentAchievements" :key="achievement.id" class="achievement-badge">
<span class="achievement-icon">🏅</span>
<span class="achievement-title">{{ achievement.title }}</span>
</div>
</div>
</div>
<button class="btn btn-outline" @click="$router.push('/achievements')">
📜 Все достижения
</button>
</div>
<!-- План тренировок -->
<div class="training-plan-section" v-if="currentTrainingPlan">
<h3>📅 Мой план тренировок</h3>
<div class="plan-card">
<div class="plan-title">{{ currentTrainingPlan.title }}</div>
<div class="plan-progress">
<div class="progress-info">
<span>Неделя {{ currentTrainingPlan.currentWeek }} из {{ currentTrainingPlan.weeks }}</span>
<span>{{ Math.round((currentTrainingPlan.currentWeek / currentTrainingPlan.weeks) * 100) }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill"
:style="{ width: Math.round((currentTrainingPlan.currentWeek / currentTrainingPlan.weeks) * 100) + '%' }">
</div>
</div>
</div>
<div class="plan-target" v-if="currentTrainingPlan.targetDistance">
Цель: {{ currentTrainingPlan.targetDistance }}
</div>
<button class="btn" @click="$router.push('/training')">
📊 Продолжить тренировки
</button>
</div>
</div>
<!-- Действия -->
<div class="profile-actions">
<button class="btn" @click="editProfile"> Редактировать профиль</button>
<button class="btn" @click="viewDetailedStats">📊 Подробная статистика</button>
<button class="btn" @click="$router.push('/personal-bests')"> Мои рекорды</button>
<button class="btn" @click="$router.push('/events')">📅 События</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,
personalBests: [],
upcomingEvents: [],
currentTrainingPlan: null
}
},
computed: {
user() {
return this.authStore.user
},
userStats() {
return this.userStore.userStats
},
userAchievements() {
return this.userStore.userAchievements
},
completedAchievements() {
return this.userStore.completedAchievements
},
recentAchievements() {
return this.userAchievements
.filter(a => a.verified)
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 3)
},
achievementProgress() {
return this.userStore.achievementProgress
},
statsError() {
return this.userStore.error
},
avatarUrl() {
if (!this.user?.avatar) return null;
let filename = this.user.avatar.trim('/').split('/').pop();
const baseUrl = 'https://begushiybashkir.ru/api/v1/user/avatars/';
console.log(baseUrl)
return baseUrl + filename;
},
joinDate() {
if (!this.user?.created_at) {
return 'Неизвестно';
}
// Пробуем разные форматы даты
let date;
if (this.user.created_at.includes('T')) {
// ISO формат
date = new Date(this.user.created_at);
} else {
// Другие форматы
date = new Date(this.user.created_at.replace(' ', 'T'));
}
console.log('DEBUG - parsed date:', date);
if (isNaN(date.getTime())) {
// Если все еще ошибка, пробуем убрать микросекунды
const cleanDate = this.user.created_at.split('.')[0];
date = new Date(cleanDate);
if (isNaN(date.getTime())) {
return 'Неизвестно';
}
}
const month = date.toLocaleString('ru-RU', { month: 'long' });
const year = date.getFullYear();
// Склоняем месяц
const monthNames = {
'январь': 'января', 'февраль': 'февраля', 'март': 'марта',
'апрель': 'апреля', 'май': 'мая', 'июнь': 'июня',
'июль': 'июля', 'август': 'августа', 'сентябрь': 'сентября',
'октябрь': 'октября', 'ноябрь': 'ноября', 'декабрь': 'декабря'
};
const monthName = monthNames[month] || month;
return `${monthName} ${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: {
handleFirstInteraction() {
if (!this.hasInteracted) {
this.hasInteracted = true
this.showContent()
clearTimeout(this.autoShowTimeout)
}
},
showContent() {
this.isContentVisible = true
// Эмитим событие для показа хедера
this.$emit('show-header')
},
async onAvatarUpdated() {
this.avatarLoadError = false;
await this.authStore.fetchProfile();
},
handleAvatarError() {
console.error('Ошибка загрузки аватара:', this.avatarUrl);
this.avatarLoadError = true;
},
async loadUserData() {
this.authLoading = true;
this.avatarLoadError = false;
try {
await this.authStore.fetchProfile();
await this.loadExtendedData();
} catch (error) {
console.error('Ошибка загрузки данных:', error);
} finally {
this.authLoading = false;
}
},
async loadExtendedData() {
this.statsLoading = true;
try {
const [statsResult, achievementsResult, bestsResult, eventsResult, planResult] = await Promise.all([
this.userStore.fetchUserStats(),
this.userStore.fetchUserAchievements(),
this.fetchPersonalBests(),
this.fetchUpcomingEvents(),
this.fetchCurrentTrainingPlan()
]);
if (!statsResult.success) console.error('Ошибка статистики:', statsResult.error);
if (!achievementsResult.success) console.error('Ошибка достижений:', achievementsResult.error);
if (!bestsResult.success) console.error('Ошибка рекордов:', bestsResult.error);
if (!eventsResult.success) console.error('Ошибка событий:', eventsResult.error);
if (!planResult.success) console.error('Ошибка плана:', planResult.error);
} catch (error) {
console.error('Ошибка загрузки расширенных данных:', error);
} finally {
this.statsLoading = false;
}
},
async fetchPersonalBests() {
try {
const response = await this.userStore.fetchPersonalBests;
this.personalBests = response.data;
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
},
async fetchUpcomingEvents() {
try {
const response = await this.userStore.apiClient.get('/events/upcoming');
this.upcomingEvents = response.data;
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
},
async fetchCurrentTrainingPlan() {
try {
const response = await this.userStore.apiClient.get('/training-plans/current');
this.currentTrainingPlan = response.data;
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
},
formatTime(minutes) {
if (!minutes) return '0 мин';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return hours > 0 ? `${hours}ч ${mins}мин` : `${mins} мин`;
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('ru-RU');
},
formatEventDate(dateString) {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long'
});
},
getDistanceLabel(distanceType) {
const labels = {
'5k': '5 км',
'10k': '10 км',
'half_marathon': 'Полумарафон',
'marathon': 'Марафон',
'other': 'Другая дистанция'
};
return labels[distanceType] || distanceType;
},
getEventTypeLabel(eventType) {
const labels = {
'race': 'Забег',
'training': 'Тренировка',
'social': 'Встреча',
'workshop': 'Семинар'
};
return labels[eventType] || eventType;
},
async refreshStats() {
await this.loadExtendedData();
},
async handleLogout() {
await this.authStore.logout();
this.$router.push('/');
},
editProfile() {
this.$router.push('/profile/edit');
},
viewDetailedStats() {
this.$router.push('/stats/detailed');
}
},
async mounted() {
if (!this.user) {
await this.loadUserData();
} else {
await this.loadExtendedData();
}
window.addEventListener('scroll', this.handleFirstInteraction, { passive: true, once: true })
window.addEventListener('click', this.handleFirstInteraction, { once: true })
window.addEventListener('touchstart', this.handleFirstInteraction, { once: true })
this.autoShowTimeout = setTimeout(() => {
if (!this.hasInteracted) {
this.showContent()
}
}, 3000)
},
beforeUnmount() {
// Убираем обработчики при размонтировании
window.removeEventListener('scroll', this.handleFirstInteraction)
window.removeEventListener('click', this.handleFirstInteraction)
window.removeEventListener('touchstart', this.handleFirstInteraction)
clearTimeout(this.autoShowTimeout)
},
}
</script>
<style scoped>
/* Добавляем новые стили для дополнительных секций */
.personal-bests-section,
.events-section,
.training-plan-section {
background: white;
padding: 1.5rem;
border-radius: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.bests-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.best-card {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
padding: 1rem;
border-radius: 10px;
text-align: center;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.best-card:hover {
transform: translateY(-3px);
border-color: #2e8b57;
}
.best-type {
font-weight: bold;
color: #2e8b57;
margin-bottom: 0.5rem;
}
.best-time {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.best-date {
color: #666;
font-size: 0.9rem;
}
.best-event {
color: #555;
font-style: italic;
margin-top: 0.5rem;
}
.verified-badge {
background: #2e8b57;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
margin-top: 0.5rem;
display: inline-block;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 1rem 0;
}
.event-card {
background: #f8f9fa;
padding: 1rem;
border-radius: 10px;
border-left: 4px solid #2e8b57;
position: relative;
}
.event-date {
font-weight: bold;
color: #2e8b57;
}
.event-title {
font-weight: 600;
margin: 0.25rem 0;
}
.event-location {
color: #666;
font-size: 0.9rem;
}
.event-distance {
background: #e9ecef;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
display: inline-block;
margin-top: 0.5rem;
}
.event-type {
position: absolute;
top: 1rem;
right: 1rem;
background: #6c757d;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
}
.event-type.race {
background: #dc3545;
}
.event-type.training {
background: #2e8b57;
}
.event-type.social {
background: #fd7e14;
}
.event-type.workshop {
background: #6f42c1;
}
.plan-card {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
padding: 1.5rem;
border-radius: 12px;
margin: 1rem 0;
border: 2px solid #e9ecef;
}
.plan-title {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 1rem;
color: #333;
}
.plan-progress {
margin-bottom: 1rem;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: #666;
}
.plan-target {
background: #2e8b57;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
display: inline-block;
margin-bottom: 1rem;
font-weight: 500;
}
.recent-achievements {
margin: 1rem 0;
}
.achievements-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.5rem 0;
}
.achievement-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #ffd700;
}
.achievement-icon {
font-size: 1.2rem;
}
.achievement-title {
font-weight: 500;
}
/* Адаптивность */
@media (max-width: 768px) {
.bests-grid {
grid-template-columns: 1fr;
}
.event-type {
position: static;
margin-top: 0.5rem;
display: inline-block;
}
}
.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 {
position: relative;
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);
isolation: isolate;
/* Новое свойство */
background-image: none !important;
box-shadow: 0.2rem 0.2rem 0.3rem rgb(155, 155, 155);
/* Явно убираем фоновое изображение */
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
display: block;
box-shadow: 0.2rem 0.2rem 0.3rem rgb(155, 155, 155);
/* Убедитесь, что block */
}
.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;
}
.avatar-image:hover {
transform: scale(1.05);
}
.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>