Files
tp/serv_nginx/bbvue/src/views/Reviews.vue
T

1934 lines
44 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="reviews-page">
<!-- Герой-секция -->
<section class="hero-section">
<div class="container">
<div class="hero-content">
<h1 class="hero-title"> Отзывы участников</h1>
<p class="hero-subtitle">Реальные истории и достижения наших бегунов</p>
<div class="hero-stats">
<div class="stat">
<div class="stat-number">{{ stats.average_rating || 0 }}</div>
<div class="stat-label">Средний рейтинг</div>
</div>
<div class="stat">
<div class="stat-number">{{ stats.total_reviews || 0 }}</div>
<div class="stat-label">Отзывов</div>
</div>
<div class="stat">
<div class="stat-number">{{ stats.success_stories || 0 }}</div>
<div class="stat-label">Историй успеха</div>
</div>
</div>
</div>
</div>
</section>
<!-- Основной контент -->
<section class="reviews-section">
<div class="container">
<div class="reviews-header">
<h2 class="section-title">Что говорят наши участники</h2>
<div class="reviews-controls">
<div class="sort-controls">
<label for="sort">Сортировка:</label>
<select id="sort" v-model="sortBy" @change="loadReviews">
<option value="newest">Сначала новые</option>
<option value="oldest">Сначала старые</option>
<option value="highest">Высокий рейтинг</option>
<option value="lowest">Низкий рейтинг</option>
</select>
</div>
<div class="filter-controls">
<button v-for="filter in ratingFilters" :key="filter.value"
:class="['filter-btn', { 'active': activeRatingFilter === filter.value }]"
@click="filterByRating(filter.value)">
{{ filter.label }}
</button>
</div>
</div>
</div>
<!-- Загрузка -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>Загрузка отзывов...</p>
</div>
<!-- Сообщение об отсутствии отзывов -->
<div v-else-if="reviews.length === 0" class="no-reviews">
<h3>😔 Отзывов пока нет</h3>
<p>Будьте первым, кто поделится своим опытом!</p>
</div>
<!-- Сетка отзывов -->
<div v-else class="reviews-grid">
<div v-for="review in reviews" :key="review.id" class="review-card"
:class="getReviewCardClass(review.rating)">
<div class="review-header">
<div class="reviewer-info">
<div class="reviewer-avatar">
{{ getInitials(review.author) }}
</div>
<div class="reviewer-details">
<h4 class="review-author">{{ review.author.first_name }} {{ review.author.last_name }}</h4>
<p class="reviewer-achievement" v-if="review.achievement">
🏆 {{ review.achievement }}
</p>
<p class="review-membership">
Участник клуба
</p>
</div>
</div>
<div class="review-rating">
<div class="stars">
<span v-for="star in 5" :key="star" :class="['star', { 'filled': star <= review.rating }]">
</span>
</div>
<span class="rating-value">{{ review.rating }}/5</span>
</div>
</div>
<div class="review-content">
<p class="review-text">{{ review.text }}</p>
<div class="review-meta">
<div class="meta-items">
<span class="meta-item" v-if="review.distance">
🏃 {{ review.distance }}
</span>
<span class="meta-item" v-if="review.improvement">
📈 {{ review.improvement }}
</span>
<span class="meta-item" v-if="review.trainings">
💪 {{ review.trainings }} тренировок
</span>
</div>
<span class="review-date">{{ formatDate(review.created_at) }}</span>
</div>
<div class="review-actions" v-if="review.verified">
<span class="verified-badge"> Проверенный отзыв</span>
</div>
<!-- Действия для своих отзывов -->
<div class="review-actions" v-if="isMyReview(review)">
<button @click="editReview(review)" class="btn-edit"> Редактировать</button>
<button @click="deleteReview(review.id)" class="btn-delete">🗑 Удалить</button>
</div>
</div>
</div>
</div>
<!-- Пагинация -->
<div class="pagination" v-if="totalPages > 1">
<button class="pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)">
</button>
<button v-for="page in visiblePages" :key="page"
:class="['pagination-btn', { 'active': currentPage === page }]" @click="changePage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)">
</button>
</div>
<!-- Статистика отзывов -->
<div class="reviews-stats">
<h3>📊 Распределение оценок</h3>
<div class="rating-bars">
<div v-for="rating in 5" :key="rating" class="rating-bar">
<div class="rating-label">
<span class="stars-small">
<span v-for="star in 5" :key="star" :class="['star-small', { 'filled': star <= (6 - rating) }]">
</span>
</span>
</div>
<div class="bar-container">
<div class="bar-fill" :style="{ width: getRatingPercentage(6 - rating) + '%' }"></div>
</div>
<div class="rating-count">
{{ getRatingCount(6 - rating) }}
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Форма отзыва -->
<section class="add-review-section" id="add-review">
<div class="container">
<div class="add-review-content">
<div class="form-header">
<h2 class="section-title"> Поделитесь своим опытом</h2>
<p>Расскажите о своих достижениях и впечатлениях от тренировок</p>
</div>
<form class="review-form" @submit.prevent="submitReview" v-if="!isAuthenticated">
<div class="auth-required">
<div class="auth-message">
<h3>🔐 Войдите, чтобы оставить отзыв</h3>
<p>Только участники клуба могут оставлять отзывы</p>
</div>
<div class="auth-actions">
<router-link to="/login" class="btn btn-primary">
🔑 Войти в аккаунт
</router-link>
<router-link to="/register" class="btn btn-secondary">
🏃 Стать участником
</router-link>
</div>
</div>
</form>
<form class="review-form" @submit.prevent="submitReview" v-else>
<div class="form-group">
<label>Ваша оценка *</label>
<div class="rating-input">
<button v-for="star in 5" :key="star" type="button"
:class="['star-btn', { 'active': newReview.rating >= star }]" @click="newReview.rating = star">
</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="achievement">Ваше достижение</label>
<input id="achievement" v-model="newReview.achievement" type="text"
placeholder="Например: пробежал первый марафон">
</div>
<div class="form-group">
<label for="distance">Любимая дистанция</label>
<select id="distance" v-model="newReview.distance">
<option value="">Выберите дистанцию</option>
<option value="5 км">5 км</option>
<option value="10 км">10 км</option>
<option value="21.1 км">Полумарафон (21.1 км)</option>
<option value="42.2 км">Марафон (42.2 км)</option>
<option value="Трейл">Трейл</option>
</select>
</div>
</div>
<div class="form-group">
<label for="review-text">Ваш отзыв *</label>
<textarea id="review-text" v-model="newReview.text" rows="5"
placeholder="Расскажите о вашем опыте в клубе, достижениях, атмосфере..." required></textarea>
<div class="char-counter">
{{ newReview.text.length }}/500 символов
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="!isFormValid || submitting">
<span v-if="submitting">📤 Отправка...</span>
<span v-else>📝 {{ editingReview ? 'Обновить отзыв' : 'Опубликовать отзыв' }}</span>
</button>
<button type="button" class="btn btn-outline" @click="resetForm">
🗑 Очистить
</button>
</div>
</form>
</div>
</div>
</section>
<!-- Призыв к действию -->
<section class="cta-section">
<div class="container">
<div class="cta-content">
<h2>Готовы присоединиться к нашему сообществу?</h2>
<p>Станьте следующей историей успеха в нашем клубе!</p>
<div class="success-stories-preview">
<div class="story-card">
<div class="story-avatar">А</div>
<div class="story-content">
<h4>Анна</h4>
<p>"С нуля до марафона за 1 год! Спасибо тренеру и команде!"</p>
<span class="story-achievement">🏅 Марафон 4:20:15</span>
</div>
</div>
<div class="story-card">
<div class="story-avatar">Д</div>
<div class="story-content">
<h4>Данил</h4>
<p>"Нашел друзей и улучшил результат на 10км на 15 минут"</p>
<span class="story-achievement"> 10км за 42:30</span>
</div>
</div>
</div>
<div class="cta-features">
<div class="cta-feature">
<div class="feature-icon">👨🏫</div>
<div class="feature-text">
<strong>Профессиональный тренер</strong>
<span>Мастер спорта с индивидуальным подходом</span>
</div>
</div>
<div class="cta-feature">
<div class="feature-icon">👥</div>
<div class="feature-text">
<strong>Дружное сообщество</strong>
<span>Поддержка и мотивация единомышленников</span>
</div>
</div>
<div class="cta-feature">
<div class="feature-icon">📈</div>
<div class="feature-text">
<strong>Личный прогресс</strong>
<span>Регулярное улучшение результатов</span>
</div>
</div>
</div>
<div class="cta-actions">
<router-link to="/training" class="btn btn-primary btn-large">
🏃 Записаться на тренировку
</router-link>
<router-link to="/register" class="btn btn-secondary">
👥 Вступить в клуб
</router-link>
</div>
<div class="cta-contacts">
<p>Есть вопросы? Свяжитесь с нами:</p>
<div class="contact-links">
<a href="https://t.me/begushiybashkir" class="contact-link">📱 Telegram</a>
<a href="tel:+79273093095" class="contact-link">📞 +7 (927) 30-93-095</a>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { apiClient } from '../stores/helpers/api';
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Reviews',
data() {
return {
isAuthenticated: false,
currentUser: null,
loading: false,
sortBy: 'newest',
activeRatingFilter: 'all',
currentPage: 1,
reviewsPerPage: 6,
totalPages: 1,
totalItems: 0,
submitting: false,
editingReview: null,
stats: {
total_reviews: 0,
average_rating: 0,
success_stories: 0,
rating_distribution: {5: 0, 4: 0, 3: 0, 2: 0, 1: 0}
},
ratingFilters: [
{ value: 'all', label: 'Все оценки' },
{ value: '5', label: '⭐ 5 звезд' },
{ value: '4', label: '⭐ 4+ звезды' },
{ value: '3', label: '⭐ 3+ звезды' }
],
newReview: {
rating: 0,
text: '',
achievement: '',
distance: '',
improvement: '',
trainings: 0
},
reviews: []
}
},
computed: {
visiblePages() {
const pages = []
const startPage = Math.max(1, this.currentPage - 2)
const endPage = Math.min(this.totalPages, startPage + 4)
for (let i = startPage; i <= endPage; i++) {
pages.push(i)
}
return pages
},
isFormValid() {
return this.newReview.rating > 0 &&
this.newReview.text.length >= 10 &&
this.newReview.text.length <= 500
}
},
methods: {
handleFirstInteraction() {
if (!this.hasInteracted) {
this.hasInteracted = true
this.showContent()
clearTimeout(this.autoShowTimeout)
}
},
showContent() {
this.isContentVisible = true
// Эмитим событие для показа хедера
this.$emit('show-header')
},
async loadReviews() {
this.loading = true
try {
const params = {
page: this.currentPage,
limit: this.reviewsPerPage,
sort: this.sortBy,
filter: this.activeRatingFilter === 'all' ? '' : this.activeRatingFilter
}
// Удаляем пустые параметры
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await apiClient.get('/reviews', { params })
this.reviews = response.data.reviews || []
this.totalPages = response.data.total_pages || 1
this.totalItems = response.data.total_items || 0
this.currentPage = response.data.current_page || 1
} catch (error) {
console.error('Ошибка при загрузке отзывов:', error)
this.showNotification('error', 'Ошибка', 'Не удалось загрузить отзывы')
} finally {
this.loading = false
}
},
async loadStats() {
try {
const response = await apiClient.get('/reviews/stats')
this.stats = response.data
} catch (error) {
console.error('Ошибка при загрузке статистики:', error)
// Устанавливаем значения по умолчанию при ошибке
this.stats = {
total_reviews: 0,
average_rating: 0,
success_stories: 0,
rating_distribution: {5: 0, 4: 0, 3: 0, 2: 0, 1: 0}
}
// Не показываем ошибку пользователю для статистики
}
},
async checkAuth() {
try {
const token = localStorage.getItem('auth_token')
if (token) {
try {
// Пытаемся получить профиль пользователя для проверки авторизации
const response = await apiClient.get('/user/profile')
this.isAuthenticated = true
this.currentUser = response.data
} catch (error) {
console.error('Токен невалиден:', error)
this.handleAuthError()
}
} else {
this.isAuthenticated = false
this.currentUser = null
}
} catch (error) {
console.error('Ошибка проверки авторизации:', error)
this.handleAuthError()
}
},
handleAuthError() {
this.isAuthenticated = false
this.currentUser = null
localStorage.removeItem('auth_token')
// НЕ перенаправляем на login, чтобы пользователь мог просматривать отзывы
},
getInitials(author) {
if (!author) return '?'
const firstName = author.first_name || ''
const lastName = author.last_name || ''
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase() || '?'
},
getReviewCardClass(rating) {
return `rating-${rating}`
},
formatDate(dateString) {
try {
const date = new Date(dateString)
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
})
} catch (error) {
console.log(error)
return 'Неизвестная дата'
}
},
filterByRating(rating) {
this.activeRatingFilter = rating
this.currentPage = 1
this.loadReviews()
},
changePage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page
this.loadReviews()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
},
getRatingCount(rating) {
return this.stats.rating_distribution?.[rating] || 0
},
getRatingPercentage(rating) {
const count = this.getRatingCount(rating)
const total = this.stats.total_reviews || 1
return (count / total) * 100
},
isMyReview(review) {
return this.isAuthenticated &&
this.currentUser &&
review.author &&
review.author.id === this.currentUser.id
},
async submitReview() {
if (!this.isFormValid) {
this.showNotification('error', 'Ошибка', 'Пожалуйста, заполните все обязательные поля правильно')
return
}
this.submitting = true
try {
const reviewData = {
rating: this.newReview.rating,
text: this.newReview.text,
achievement: this.newReview.achievement,
distance: this.newReview.distance,
improvement: this.newReview.improvement,
trainings: this.newReview.trainings
}
if (this.editingReview) {
// Редактирование существующего отзыва
await apiClient.put(`/reviews/${this.editingReview.id}`, reviewData)
this.showNotification('success', 'Успех', 'Отзыв успешно обновлен!')
this.editingReview = null
} else {
// Создание нового отзыва
await apiClient.post('/reviews', reviewData)
this.showNotification('success', 'Успех', 'Спасибо за ваш отзыв! После проверки модератором он будет опубликован.')
}
this.resetForm()
this.loadReviews()
this.loadStats()
} catch (error) {
console.error('Ошибка при отправке отзыва:', error)
let errorMessage = 'Произошла ошибка при отправке отзыва. Попробуйте еще раз.'
if (error.response?.status === 401) {
errorMessage = 'Необходимо авторизоваться для отправки отзыва'
this.handleAuthError()
} else if (error.response?.status === 403) {
errorMessage = 'У вас нет прав для выполнения этого действия'
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message
}
this.showNotification('error', 'Ошибка', errorMessage)
} finally {
this.submitting = false
}
},
async editReview(review) {
this.editingReview = review
this.newReview = {
rating: review.rating,
text: review.text,
achievement: review.achievement || '',
distance: review.distance || '',
improvement: review.improvement || '',
trainings: review.trainings || 0
}
// Прокручиваем к форме
document.getElementById('add-review').scrollIntoView({
behavior: 'smooth'
})
},
async deleteReview(reviewId) {
if (!confirm('Вы уверены, что хотите удалить этот отзыв?')) {
return
}
try {
await apiClient.delete(`/reviews/${reviewId}`)
this.loadReviews()
this.loadStats()
this.showNotification('success', 'Успех', 'Отзыв успешно удален')
} catch (error) {
console.error('Ошибка при удалении отзыва:', error)
let errorMessage = 'Не удалось удалить отзыв'
if (error.response?.status === 403) {
errorMessage = 'Вы можете удалять только свои отзывы'
} else if (error.response?.status === 401) {
errorMessage = 'Необходимо авторизоваться'
this.handleAuthError()
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message
}
this.showNotification('error', 'Ошибка', errorMessage)
}
},
resetForm() {
this.newReview = {
rating: 0,
text: '',
achievement: '',
distance: '',
improvement: '',
trainings: 0
}
this.editingReview = null
},
showNotification(type, title, text) {
// Используем существующую систему уведомлений или создаем простую альтернативу
if (this.$notify) {
this.$notify({
type: type,
title: title,
text: text
})
} else {
// Простая альтернатива, если $notify не доступен
const notification = document.createElement('div')
notification.className = `notification notification-${type}`
notification.innerHTML = `
<strong>${title}</strong>
<p>${text}</p>
`
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification)
}
}, 5000)
}
}
},
mounted() {
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)
this.checkAuth()
this.loadReviews()
this.loadStats()
},
beforeUnmount() {
// Убираем обработчики при размонтировании
window.removeEventListener('scroll', this.handleFirstInteraction)
window.removeEventListener('click', this.handleFirstInteraction)
window.removeEventListener('touchstart', this.handleFirstInteraction)
clearTimeout(this.autoShowTimeout)
},
watch: {
sortBy() {
this.currentPage = 1
this.loadReviews()
},
activeRatingFilter() {
this.currentPage = 1
this.loadReviews()
}
}
}
</script>
<style scoped>
/* Стили остаются без изменений, как в вашем исходном файле */
/* Добавляем только стили для уведомлений */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideInRight 0.3s ease;
max-width: 400px;
}
.notification-success {
background: #27ae60;
color: white;
}
.notification-error {
background: #e74c3c;
color: white;
}
.notification-warning {
background: #f39c12;
color: white;
}
.notification-info {
background: #3498db;
color: white;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Остальные стили остаются такими же как в вашем файле */
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-reviews {
text-align: center;
padding: 3rem;
color: #666;
}
.btn-edit,
.btn-delete {
background: none;
border: none;
color: #2e8b57;
cursor: pointer;
font-size: 0.8rem;
margin-right: 1rem;
}
.btn-delete {
color: #e74c3c;
}
.btn-edit:hover {
text-decoration: underline;
}
.btn-delete:hover {
text-decoration: underline;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideInRight 0.3s ease;
max-width: 400px;
}
.notification-success {
background: #27ae60;
color: white;
}
.notification-error {
background: #e74c3c;
color: white;
}
.notification-warning {
background: #f39c12;
color: white;
}
.notification-info {
background: #3498db;
color: white;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Остальные стили остаются такими же как в вашем файле */
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-reviews {
text-align: center;
padding: 3rem;
color: #666;
}
.btn-edit,
.btn-delete {
background: none;
border: none;
color: #2e8b57;
cursor: pointer;
font-size: 0.8rem;
margin-right: 1rem;
}
.btn-delete {
color: #e74c3c;
}
.btn-edit:hover {
text-decoration: underline;
}
.btn-delete:hover {
text-decoration: underline;
}
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.no-reviews {
text-align: center;
padding: 3rem;
color: #666;
}
.btn-edit,
.btn-delete {
background: none;
border: none;
color: #2e8b57;
cursor: pointer;
font-size: 0.8rem;
margin-right: 1rem;
}
.btn-delete {
color: #e74c3c;
}
.btn-edit:hover {
text-decoration: underline;
}
.btn-delete:hover {
text-decoration: underline;
}
.reviews-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;
}
.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 {
font-size: 2.5rem;
color: #2e8b57;
margin-bottom: 1rem;
font-weight: 700;
}
/* Заголовок отзывов */
.reviews-header {
margin-bottom: 3rem;
}
.reviews-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.sort-controls label {
font-weight: 600;
color: #333;
}
.sort-controls select {
padding: 8px 12px;
border: 2px solid #e9ecef;
border-radius: 8px;
background: white;
cursor: pointer;
}
.filter-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 8px 16px;
border: 2px solid #e9ecef;
background: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.9rem;
}
.filter-btn:hover {
border-color: #2e8b57;
}
.filter-btn.active {
background: #2e8b57;
color: white;
border-color: #2e8b57;
}
/* Сетка отзывов */
.reviews-section {
padding: 4rem 0;
}
.reviews-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.review-card {
background: white;
border-radius: 15px;
padding: 2rem;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 2px solid transparent;
}
.review-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
/* Стили по рейтингу */
.review-card.rating-5 {
border-color: #ffd700;
background: linear-gradient(135deg, #fff9e6 0%, #fff3cc 100%);
}
.review-card.rating-4 {
border-color: #a8e6cf;
background: linear-gradient(135deg, #f0f8f0 0%, #e8f5e9 100%);
}
.review-card.rating-3 {
border-color: #ffd8b2;
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
}
.review-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.reviewer-info {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.reviewer-avatar {
width: 50px;
height: 50px;
background: #2e8b57;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
flex-shrink: 0;
}
.review-author {
color: #2e8b57;
margin: 0 0 0.3rem 0;
font-size: 1.2rem;
}
.reviewer-achievement {
color: #e74c3c;
font-weight: 600;
font-size: 0.9rem;
margin: 0 0 0.3rem 0;
}
.review-membership {
color: #666;
font-size: 0.8rem;
margin: 0;
}
.review-rating {
text-align: right;
}
.stars {
display: flex;
gap: 2px;
margin-bottom: 0.5rem;
justify-content: flex-end;
}
.star {
color: #ddd;
font-size: 1.2rem;
}
.star.filled {
color: #ffd700;
}
.rating-value {
font-weight: bold;
color: #333;
font-size: 1.1rem;
}
.review-content {
space-y: 1rem;
}
.review-text {
line-height: 1.6;
color: #333;
margin-bottom: 1.5rem;
}
.review-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.meta-items {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.meta-item {
background: rgba(46, 139, 87, 0.1);
color: #2e8b57;
padding: 0.3rem 0.8rem;
border-radius: 15px;
font-size: 0.8rem;
font-weight: 600;
}
.review-date {
color: #666;
font-size: 0.9rem;
}
.review-actions {
border-top: 1px solid #e9ecef;
padding-top: 1rem;
}
.verified-badge {
color: #27ae60;
font-size: 0.8rem;
font-weight: 600;
}
/* Пагинация */
.pagination {
display: flex;
justify-content: center;
gap: 0.5rem;
margin: 3rem 0;
}
.pagination-btn {
padding: 10px 15px;
border: 2px solid #e9ecef;
background: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.pagination-btn:hover:not(:disabled) {
border-color: #2e8b57;
background: #f8fff8;
}
.pagination-btn.active {
background: #2e8b57;
color: white;
border-color: #2e8b57;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Статистика оценок */
.reviews-stats {
background: white;
padding: 2rem;
border-radius: 15px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
margin-bottom: 3rem;
}
.reviews-stats h3 {
color: #2e8b57;
margin-bottom: 1.5rem;
text-align: center;
}
.rating-bars {
max-width: 400px;
margin: 0 auto;
}
.rating-bar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.8rem;
}
.rating-label {
width: 80px;
}
.stars-small {
display: flex;
gap: 1px;
}
.star-small {
color: #ddd;
font-size: 0.8rem;
}
.star-small.filled {
color: #ffd700;
}
.bar-container {
flex: 1;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #2e8b57;
border-radius: 4px;
transition: width 0.5s ease;
}
.rating-count {
width: 30px;
text-align: right;
font-weight: 600;
color: #333;
}
/* Форма отзыва */
.add-review-section {
background: white;
padding: 4rem 0;
border-top: 1px solid #e9ecef;
}
.add-review-content {
max-width: 600px;
margin: 0 auto;
}
.form-header {
text-align: center;
margin-bottom: 3rem;
}
.form-header p {
color: #666;
font-size: 1.1rem;
}
.review-form {
background: #f8fff8;
padding: 2rem;
border-radius: 15px;
border: 2px solid #e9ecef;
}
.auth-required {
text-align: center;
padding: 2rem;
}
.auth-message {
margin-bottom: 2rem;
}
.auth-message h3 {
color: #2e8b57;
margin-bottom: 1rem;
}
.auth-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.rating-input {
display: flex;
gap: 0.5rem;
}
.star-btn {
background: none;
border: none;
font-size: 2rem;
color: #ddd;
cursor: pointer;
transition: all 0.2s ease;
padding: 0.2rem;
}
.star-btn.active {
color: #ffd700;
transform: scale(1.1);
}
.star-btn:hover {
transform: scale(1.2);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px 15px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s ease;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #2e8b57;
}
.char-counter {
text-align: right;
font-size: 0.8rem;
color: #666;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
/* CTA секция */
.cta-section {
background: linear-gradient(135deg, #2e8b57 0%, #26734a 100%);
color: white;
padding: 80px 0;
text-align: center;
}
.cta-content h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.cta-content p {
font-size: 1.2rem;
opacity: 0.9;
margin-bottom: 3rem;
}
.success-stories-preview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.story-card {
background: rgba(255, 255, 255, 0.1);
padding: 1.5rem;
border-radius: 10px;
backdrop-filter: blur(10px);
display: flex;
gap: 1rem;
align-items: flex-start;
text-align: left;
}
.story-avatar {
width: 40px;
height: 40px;
background: #ffd700;
color: #333;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.story-content h4 {
margin: 0 0 0.5rem 0;
color: white;
}
.story-content p {
font-size: 0.9rem;
opacity: 0.9;
margin: 0 0 0.5rem 0;
line-height: 1.4;
}
.story-achievement {
color: #ffd700;
font-size: 0.8rem;
font-weight: 600;
}
.cta-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.cta-feature {
display: flex;
align-items: center;
gap: 1rem;
text-align: left;
}
.feature-icon {
font-size: 2.5rem;
flex-shrink: 0;
}
.feature-text {
display: flex;
flex-direction: column;
}
.feature-text strong {
font-size: 1.1rem;
margin-bottom: 0.3rem;
}
.feature-text span {
opacity: 0.8;
font-size: 0.9rem;
}
.cta-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.cta-contacts p {
margin-bottom: 1rem;
opacity: 0.8;
}
.contact-links {
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.contact-link {
color: #ffd700;
text-decoration: none;
font-weight: 600;
transition: opacity 0.3s ease;
}
.contact-link:hover {
opacity: 0.8;
}
/* Кнопки */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
border-radius: 50px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
gap: 0.5rem;
font-size: 1rem;
cursor: pointer;
}
.btn-primary {
background: #ffd700;
color: #333;
}
.btn-primary:hover {
background: #e6c200;
transform: translateY(-2px);
}
.btn-secondary {
background: transparent;
color: rgb(162, 224, 198); /* Белый текст */
border-color: white; /* Белая рамка */
}
.btn-secondary:hover {
background: white;
color: #2e8b57; /* Зеленый текст при hover */
transform: translateY(-2px);
}
.btn-outline {
background: transparent;
color: #2e8b57;
border-color: #2e8b57;
}
.btn-outline:hover {
background: #2e8b57;
color: white;
transform: translateY(-2px);
}
.btn-large {
padding: 15px 30px;
font-size: 1.1rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
/* Адаптивность */
@media (max-width: 768px) {
.hero-title {
font-size: 2.2rem;
}
.hero-stats {
gap: 2rem;
}
.stat-number {
font-size: 2rem;
}
.reviews-controls {
flex-direction: column;
align-items: stretch;
}
.sort-controls {
justify-content: space-between;
}
.filter-controls {
justify-content: center;
}
.reviews-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.review-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.review-rating {
align-self: flex-end;
}
.success-stories-preview {
grid-template-columns: 1fr;
}
.cta-features {
grid-template-columns: 1fr;
}
.cta-actions {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 300px;
}
.auth-actions {
flex-direction: column;
}
}
@media (max-width: 480px) {
.hero-section {
padding: 60px 0 40px;
}
.hero-title {
font-size: 1.8rem;
}
.section-title {
font-size: 2rem;
}
.container {
padding: 0 15px;
}
.review-card {
padding: 1.5rem;
}
.reviewer-info {
flex-direction: column;
text-align: center;
}
.reviewer-avatar {
align-self: center;
}
.review-meta {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.meta-items {
flex-direction: column;
gap: 0.5rem;
}
}
/* Анимации */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.review-card {
animation: fadeInUp 0.6s ease;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.star-btn:hover {
animation: pulse 0.3s ease;
}
/* Задержки для анимаций карточек */
.review-card:nth-child(odd) {
animation-delay: 0.1s;
}
.review-card:nth-child(even) {
animation-delay: 0.2s;
}
/* Состояния загрузки */
.loading {
opacity: 0.6;
pointer-events: none;
}
/* Улучшения доступности */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Фокус-стили */
button:focus-visible,
a:focus-visible,
select:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 2px solid #2e8b57;
outline-offset: 2px;
}
/* Темная тема */
@media (prefers-color-scheme: dark) {
.reviews-page {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
}
.review-card {
background: #2d2d2d;
color: #ffffff;
}
.review-text {
color: #cccccc;
}
.form-group input,
.form-group select,
.form-group textarea {
background: #3d3d3d;
border-color: #555;
color: white;
}
.review-form {
background: #2d2d2d;
border-color: #555;
}
}
/* Кастомный скроллбар */
.reviews-section::-webkit-scrollbar {
width: 8px;
}
.reviews-section::-webkit-scrollbar-track {
background: #f1f1f1;
}
.reviews-section::-webkit-scrollbar-thumb {
background: #2e8b57;
border-radius: 4px;
}
.reviews-section::-webkit-scrollbar-thumb:hover {
background: #26734a;
}
/* Утилитарные классы */
.text-center {
text-align: center;
}
.mb-2 {
margin-bottom: 2rem;
}
.mt-2 {
margin-top: 2rem;
}
/* Состояния валидации формы */
.form-group.error input,
.form-group.error textarea,
.form-group.error select {
border-color: #e74c3c;
}
.error-message {
color: #e74c3c;
font-size: 0.8rem;
margin-top: 0.5rem;
}
/* Уведомления */
.notification {
position: fixed;
top: 20px;
right: 20px;
background: #27ae60;
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Адаптация для печати */
@media print {
.hero-section,
.add-review-section,
.cta-section,
.review-actions,
.pagination {
display: none;
}
.review-card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #ddd;
}
}
</style>