feat: Add complete reviews system with star ratings
✨ New Features: - Reviews system with 1-5 star ratings - User can add, edit, and delete their own reviews - One review per product per user (DB constraint) - Automatic average rating calculation - Review count tracking - Interactive star selection UI - AJAX-powered review submission - Responsive design for all devices 🗄️ Database: - New 'reviews' table with full structure - Added 'rating' and 'review_count' fields to products - PostgreSQL triggers for automatic rating updates - Database functions for rating calculations - Indexes for performance optimization 📦 Backend (PHP): - Review model with 15+ methods - ReviewController with 5 actions - Updated Product model to include ratings - Updated ProductController to load reviews - 5 new API endpoints 🎨 Frontend: - Reviews list component (_reviews_list.php) - Review form component (_review_form.php) - Reviews sechow page - Star ratings in catalog view - Interactive JavaScript (200+ lines) - Adaptive styles (400+ lines) 🔒 Security: - Server-side authorization checks - XSS protection (htmlspecialchars) - SQL injection protection (PDO prepared) - Input validation (client + server) - Access control for review editing 📝 Modified Files: - app/Models/Product.php - added rating fields to queries - app/Controllers/ProductController.php - loads reviews - app/Views/products/show.php - reviews section - app/Views/products/catalog.php - star ratings - config/routes.php - review endpoints - public/style_for_cite.less - rating styles 🆕 New Files: - app/Models/Review.php - app/Controllers/ReviewController.php - app/Views/products/_reviews_list.php - app/Views/products/_review_form.php
This commit is contained in:
@@ -66,6 +66,14 @@
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php if ($product['stock_quantity'] == 0): ?>
|
||||
<form action="/admin/products/remove/<?= $product['product_id'] ?>" method="POST" style="display: inline;"
|
||||
onsubmit="return confirm('ВНИМАНИЕ! Товар будет полностью удален из базы данных. Это действие нельзя отменить. Продолжить?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Удалить из БД" style="background-color: #dc3545; margin-left: 5px;">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -155,6 +155,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary-btn">Задать вопрос</button>
|
||||
<a href="#footer" class="btn primary-btn">Задать вопрос</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -38,7 +38,17 @@
|
||||
<body>
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
<?= \App\Core\View::partial('header', ['user' => $user ?? null, 'isLoggedIn' => $isLoggedIn ?? \App\Core\View::isAuthenticated(), 'isAdmin' => $isAdmin ?? \App\Core\View::isAdmin()]) ?>
|
||||
<?php
|
||||
// Загружаем категории для header
|
||||
$categoryModel = new \App\Models\Category();
|
||||
$headerCategories = $categoryModel->getActive();
|
||||
?>
|
||||
<?= \App\Core\View::partial('header', [
|
||||
'user' => $user ?? null,
|
||||
'isLoggedIn' => $isLoggedIn ?? \App\Core\View::isAuthenticated(),
|
||||
'isAdmin' => $isAdmin ?? \App\Core\View::isAdmin(),
|
||||
'categories' => $headerCategories ?? []
|
||||
]) ?>
|
||||
|
||||
<main>
|
||||
<?= $content ?>
|
||||
@@ -55,6 +65,68 @@
|
||||
setTimeout(function() { notification.removeClass('show'); }, 3000);
|
||||
}
|
||||
|
||||
// Выпадающий список категорий
|
||||
(function() {
|
||||
function initCatalogDropdown() {
|
||||
var catalogDropdown = document.getElementById('catalogDropdown');
|
||||
var catalogMenu = document.getElementById('catalogMenu');
|
||||
|
||||
if (!catalogDropdown || !catalogMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Обработчик клика на выпадающий список
|
||||
catalogDropdown.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
var isActive = this.classList.contains('active');
|
||||
if (isActive) {
|
||||
this.classList.remove('active');
|
||||
catalogMenu.style.display = 'none';
|
||||
} else {
|
||||
this.classList.add('active');
|
||||
catalogMenu.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие при клике вне меню
|
||||
document.addEventListener('click', function(e) {
|
||||
if (catalogDropdown && !catalogDropdown.contains(e.target)) {
|
||||
catalogDropdown.classList.remove('active');
|
||||
if (catalogMenu) {
|
||||
catalogMenu.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие при клике на ссылку в меню (через делегирование)
|
||||
if (catalogMenu) {
|
||||
catalogMenu.addEventListener('click', function(e) {
|
||||
if (e.target.tagName === 'A') {
|
||||
catalogDropdown.classList.remove('active');
|
||||
catalogMenu.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация при загрузке DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCatalogDropdown);
|
||||
} else {
|
||||
initCatalogDropdown();
|
||||
}
|
||||
|
||||
// Резервная инициализация при полной загрузке страницы
|
||||
window.addEventListener('load', function() {
|
||||
var catalogDropdown = document.getElementById('catalogDropdown');
|
||||
if (catalogDropdown && !catalogDropdown.hasAttribute('data-initialized')) {
|
||||
catalogDropdown.setAttribute('data-initialized', 'true');
|
||||
initCatalogDropdown();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
$(document).ready(function() {
|
||||
$.get('/cart/count', function(response) {
|
||||
if (response.success) {
|
||||
|
||||
@@ -3,22 +3,30 @@ $isLoggedIn = $isLoggedIn ?? \App\Core\View::isAuthenticated();
|
||||
$isAdmin = $isAdmin ?? \App\Core\View::isAdmin();
|
||||
$user = $user ?? \App\Core\View::currentUser();
|
||||
?>
|
||||
<style>
|
||||
#catalogMenu {
|
||||
display: none;
|
||||
}
|
||||
#catalogDropdown.active #catalogMenu {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<header class="header">
|
||||
<div class="header__top">
|
||||
<div class="container header__top-content">
|
||||
<a href="/" class="logo">AETERNA</a>
|
||||
|
||||
<div class="search-catalog">
|
||||
<div class="catalog-dropdown">
|
||||
<div class="catalog-dropdown" id="catalogDropdown">
|
||||
Все категории <span>▼</span>
|
||||
<div class="catalog-dropdown__menu">
|
||||
<div class="catalog-dropdown__menu" id="catalogMenu">
|
||||
<ul>
|
||||
<li><a href="/catalog">Все товары</a></li>
|
||||
<li><a href="/catalog?category=1">Диваны</a></li>
|
||||
<li><a href="/catalog?category=2">Кровати</a></li>
|
||||
<li><a href="/catalog?category=3">Шкафы</a></li>
|
||||
<li><a href="/catalog?category=4">Стулья</a></li>
|
||||
<li><a href="/catalog?category=5">Столы</a></li>
|
||||
<?php if (!empty($categories)): ?>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<li><a href="/catalog?category=<?= $category['category_id'] ?>"><?= htmlspecialchars($category['name']) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,48 +42,16 @@ $user = $user ?? \App\Core\View::currentUser();
|
||||
|
||||
<div class="header__icons--top">
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<?php if (!$isAdmin): ?>
|
||||
<a href="/cart" class="icon cart-icon">
|
||||
<i class="fas fa-shopping-cart"></i>
|
||||
<span class="cart-count">0</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="user-profile-dropdown">
|
||||
<div class="user-profile-toggle">
|
||||
<div class="user-avatar">
|
||||
<?= !empty($user['email']) ? strtoupper(substr($user['email'], 0, 1)) : 'U' ?>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-email"><?= htmlspecialchars($user['email'] ?? '') ?></div>
|
||||
<div class="user-status <?= $isAdmin ? 'admin' : 'user' ?>">
|
||||
<?= $isAdmin ? 'Админ' : 'Пользователь' ?>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down" style="font-size: 12px; color: #666;"></i>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-menu">
|
||||
<div class="user-profile-header">
|
||||
<div class="user-profile-name">
|
||||
<i class="fas fa-user"></i>
|
||||
<?= htmlspecialchars($user['full_name'] ?? $user['email'] ?? '') ?>
|
||||
</div>
|
||||
<div class="user-profile-details">
|
||||
<small><i class="far fa-envelope"></i> <?= htmlspecialchars($user['email'] ?? '') ?></small>
|
||||
<?php if (!empty($user['login_time'])): ?>
|
||||
<br><small><i class="far fa-clock"></i> Вошел: <?= date('d.m.Y H:i', $user['login_time']) ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="user-profile-links">
|
||||
<li><a href="/cart"><i class="fas fa-shopping-bag"></i> Корзина</a></li>
|
||||
<?php if ($isAdmin): ?>
|
||||
<li><a href="/admin"><i class="fas fa-user-shield"></i> Админ-панель</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="/logout" class="logout-link"><i class="fas fa-sign-out-alt"></i> Выйти</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/logout" style="font-size: 14px; color: #666; text-decoration: none; margin-left: 15px;">
|
||||
<i class="fas fa-sign-out-alt"></i> Выйти
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<a href="/login" class="icon"><i class="far fa-user"></i></a>
|
||||
<a href="/login" style="font-size: 12px; color: #666; margin-left: 5px;">Войти</a>
|
||||
|
||||
317
app/Views/products/_review_form.php
Normal file
317
app/Views/products/_review_form.php
Normal file
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
// Partial view for review form
|
||||
// Expected variables: $productId, $userReview (optional - for editing)
|
||||
$isEditing = isset($userReview) && !empty($userReview);
|
||||
?>
|
||||
|
||||
<div class="review-form-container" id="reviewFormContainer">
|
||||
<h3><?= $isEditing ? 'Редактировать отзыв' : 'Оставить отзыв' ?></h3>
|
||||
|
||||
<form id="reviewForm" class="review-form">
|
||||
<input type="hidden" name="product_id" value="<?= $productId ?>">
|
||||
<?php if ($isEditing): ?>
|
||||
<input type="hidden" name="review_id" value="<?= $userReview['review_id'] ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Ваша оценка: <span class="required">*</span></label>
|
||||
<div class="star-rating-input" id="starRatingInput">
|
||||
<?php for ($i = 1; $i <= 5; $i++): ?>
|
||||
<span class="star-input" data-rating="<?= $i ?>">★</span>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<input type="hidden" name="rating" id="ratingValue" value="<?= $isEditing ? $userReview['rating'] : '0' ?>" required>
|
||||
<div class="rating-text" id="ratingText">
|
||||
<?php if ($isEditing): ?>
|
||||
<?= $userReview['rating'] ?> из 5
|
||||
<?php else: ?>
|
||||
Нажмите на звезды для выбора оценки
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="error-message" id="ratingError"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reviewComment">Ваш отзыв:</label>
|
||||
<textarea
|
||||
name="comment"
|
||||
id="reviewComment"
|
||||
rows="5"
|
||||
placeholder="Расскажите о вашем впечатлении о товаре..."
|
||||
maxlength="1000"
|
||||
><?= $isEditing ? htmlspecialchars($userReview['comment']) : '' ?></textarea>
|
||||
<div class="char-counter">
|
||||
<span id="charCount"><?= $isEditing ? mb_strlen($userReview['comment']) : '0' ?></span>/1000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn primary-btn" id="submitReviewBtn">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<?= $isEditing ? 'Обновить отзыв' : 'Отправить отзыв' ?>
|
||||
</button>
|
||||
<?php if ($isEditing): ?>
|
||||
<button type="button" class="btn secondary-btn" id="cancelEditBtn">
|
||||
<i class="fas fa-times"></i> Отменить
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-message" id="formMessage"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.review-form-container {
|
||||
background: #f8f9fa;
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.review-form-container h3 {
|
||||
color: #453227;
|
||||
margin-bottom: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.review-form .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.review-form label {
|
||||
display: block;
|
||||
color: #453227;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.star-rating-input {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.star-input {
|
||||
font-size: 32px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.star-input:hover,
|
||||
.star-input.hover {
|
||||
color: #ffc107;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.star-input.selected {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.review-form textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.review-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #617365;
|
||||
}
|
||||
|
||||
.char-counter {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: #617365;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: #453227;
|
||||
}
|
||||
|
||||
.primary-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.form-message {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #dc3545;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.review-form-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.star-input {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const starRatingInput = document.getElementById('starRatingInput');
|
||||
const ratingValue = document.getElementById('ratingValue');
|
||||
const ratingText = document.getElementById('ratingText');
|
||||
const stars = starRatingInput.querySelectorAll('.star-input');
|
||||
const reviewComment = document.getElementById('reviewComment');
|
||||
const charCount = document.getElementById('charCount');
|
||||
|
||||
// Set initial state if editing
|
||||
const initialRating = parseInt(ratingValue.value);
|
||||
if (initialRating > 0) {
|
||||
updateStarDisplay(initialRating);
|
||||
}
|
||||
|
||||
// Star hover effect
|
||||
stars.forEach(star => {
|
||||
star.addEventListener('mouseenter', function() {
|
||||
const rating = parseInt(this.dataset.rating);
|
||||
updateStarHover(rating);
|
||||
});
|
||||
|
||||
star.addEventListener('click', function() {
|
||||
const rating = parseInt(this.dataset.rating);
|
||||
ratingValue.value = rating;
|
||||
updateStarDisplay(rating);
|
||||
updateRatingText(rating);
|
||||
document.getElementById('ratingError').classList.remove('show');
|
||||
});
|
||||
});
|
||||
|
||||
starRatingInput.addEventListener('mouseleave', function() {
|
||||
const currentRating = parseInt(ratingValue.value);
|
||||
updateStarDisplay(currentRating);
|
||||
});
|
||||
|
||||
// Character counter
|
||||
if (reviewComment && charCount) {
|
||||
reviewComment.addEventListener('input', function() {
|
||||
charCount.textContent = this.value.length;
|
||||
});
|
||||
}
|
||||
|
||||
function updateStarHover(rating) {
|
||||
stars.forEach((star, index) => {
|
||||
if (index < rating) {
|
||||
star.classList.add('hover');
|
||||
} else {
|
||||
star.classList.remove('hover');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateStarDisplay(rating) {
|
||||
stars.forEach((star, index) => {
|
||||
star.classList.remove('hover');
|
||||
if (index < rating) {
|
||||
star.classList.add('selected');
|
||||
} else {
|
||||
star.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateRatingText(rating) {
|
||||
const texts = {
|
||||
1: '1 из 5 - Плохо',
|
||||
2: '2 из 5 - Неудовлетворительно',
|
||||
3: '3 из 5 - Нормально',
|
||||
4: '4 из 5 - Хорошо',
|
||||
5: '5 из 5 - Отлично!'
|
||||
};
|
||||
ratingText.textContent = texts[rating] || 'Нажмите на звезды для выбора оценки';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
208
app/Views/products/_reviews_list.php
Normal file
208
app/Views/products/_reviews_list.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
// Partial view for displaying product reviews
|
||||
// Expected variables: $reviews, $currentUserId, $isAdmin
|
||||
?>
|
||||
|
||||
<div class="reviews-list" id="reviewsList">
|
||||
<?php if (empty($reviews)): ?>
|
||||
<div class="no-reviews">
|
||||
<p>Пока нет отзывов об этом товаре. Будьте первым!</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($reviews as $review): ?>
|
||||
<div class="review-item" data-review-id="<?= $review['review_id'] ?>" data-user-id="<?= $review['user_id'] ?>">
|
||||
<div class="review-header">
|
||||
<div class="review-author-info">
|
||||
<div class="review-author-avatar">
|
||||
<?= strtoupper(mb_substr($review['full_name'], 0, 1)) ?>
|
||||
</div>
|
||||
<div>
|
||||
<div class="review-author-name"><?= htmlspecialchars($review['full_name']) ?></div>
|
||||
<div class="review-date">
|
||||
<?= date('d.m.Y', strtotime($review['created_at'])) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-rating">
|
||||
<?php for ($i = 1; $i <= 5; $i++): ?>
|
||||
<span class="star <?= $i <= $review['rating'] ? 'filled' : '' ?>">★</span>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($review['comment'])): ?>
|
||||
<div class="review-comment">
|
||||
<?= nl2br(htmlspecialchars($review['comment'])) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($currentUserId) && ($review['user_id'] == $currentUserId || $isAdmin)): ?>
|
||||
<div class="review-actions">
|
||||
<?php if ($review['user_id'] == $currentUserId): ?>
|
||||
<button class="btn-small btn-edit-review" data-review-id="<?= $review['review_id'] ?>"
|
||||
data-rating="<?= $review['rating'] ?>"
|
||||
data-comment="<?= htmlspecialchars($review['comment']) ?>">
|
||||
<i class="fas fa-edit"></i> Редактировать
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button class="btn-small btn-danger btn-delete-review" data-review-id="<?= $review['review_id'] ?>">
|
||||
<i class="fas fa-trash"></i> Удалить
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($isAdmin && !$review['is_approved']): ?>
|
||||
<div class="review-approval-notice">
|
||||
<i class="fas fa-exclamation-circle"></i> Отзыв ожидает модерации
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reviews-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.no-reviews {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.review-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.review-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.review-author-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.review-author-avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #617365, #453227);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.review-author-name {
|
||||
font-weight: bold;
|
||||
color: #453227;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.review-date {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.review-rating {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.review-rating .star {
|
||||
font-size: 18px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.review-rating .star.filled {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.review-comment {
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-edit-review {
|
||||
background: #617365;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit-review:hover {
|
||||
background: #453227;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.review-approval-notice {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.review-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,11 @@ use App\Core\View;
|
||||
.product-card img { width: 100%; height: 200px; object-fit: cover; }
|
||||
.product-info { padding: 15px; }
|
||||
.product-info .name { font-weight: bold; color: #453227; margin-bottom: 5px; }
|
||||
.product-info .product-rating { display: flex; align-items: center; gap: 5px; margin: 8px 0; }
|
||||
.product-info .product-rating .stars { display: flex; gap: 1px; }
|
||||
.product-info .product-rating .star { font-size: 14px; color: #ddd; }
|
||||
.product-info .product-rating .star.filled { color: #ffc107; }
|
||||
.product-info .product-rating .rating-text { font-size: 12px; color: #666; }
|
||||
.product-info .price { color: #617365; font-size: 18px; font-weight: bold; }
|
||||
.add-to-cart-btn { position: absolute; bottom: 15px; right: 15px; background: rgba(255,255,255,0.9); width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #453227; border: 1px solid #eee; transition: all 0.3s; }
|
||||
.add-to-cart-btn:hover { background: #453227; color: white; }
|
||||
@@ -47,12 +52,21 @@ use App\Core\View;
|
||||
<h3 style="margin-bottom: 15px; color: #453227;">
|
||||
<i class="fas fa-user-shield"></i> Панель управления каталогом
|
||||
</h3>
|
||||
<div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<a href="/admin/products" class="admin-btn"><i class="fas fa-boxes"></i> Управление каталогом</a>
|
||||
<a href="/admin/products/add" class="admin-btn"><i class="fas fa-plus"></i> Добавить товар</a>
|
||||
<a href="/admin/categories" class="admin-btn"><i class="fas fa-tags"></i> Категории</a>
|
||||
<a href="/admin/orders" class="admin-btn"><i class="fas fa-shopping-cart"></i> Заказы</a>
|
||||
</div>
|
||||
<div style="padding: 10px; background: white; border-radius: 4px; display: inline-block;">
|
||||
<strong style="margin-right: 10px;">Отображение:</strong>
|
||||
<a href="/catalog?show_all=1" class="admin-btn" style="<?= $showAll ? 'background: #453227;' : '' ?>">
|
||||
<i class="fas fa-list"></i> Все товары
|
||||
</a>
|
||||
<a href="/catalog?show_all=0" class="admin-btn" style="<?= !$showAll ? 'background: #453227;' : '' ?>">
|
||||
<i class="fas fa-check-circle"></i> Только доступные
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -160,9 +174,23 @@ use App\Core\View;
|
||||
alt="<?= htmlspecialchars($product['name']) ?>">
|
||||
<div class="product-info">
|
||||
<div class="name"><?= htmlspecialchars($product['name']) ?></div>
|
||||
<?php if (isset($product['rating']) && $product['rating'] > 0): ?>
|
||||
<div class="product-rating">
|
||||
<div class="stars">
|
||||
<?php
|
||||
$rating = $product['rating'] ?? 0;
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$filled = $i <= round($rating);
|
||||
echo '<span class="star ' . ($filled ? 'filled' : '') . '">★</span>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<span class="rating-text"><?= number_format($product['rating'], 1) ?> (<?= $product['review_count'] ?? 0 ?>)</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="price"><?= View::formatPrice($product['price']) ?></div>
|
||||
</div>
|
||||
<?php if ($product['is_available']): ?>
|
||||
<?php if (!$isAdmin && $product['is_available']): ?>
|
||||
<i class="fas fa-shopping-cart add-to-cart-btn"
|
||||
onclick="event.stopPropagation(); addToCart(<?= $product['product_id'] ?>, '<?= addslashes($product['name']) ?>')"
|
||||
title="Добавить в корзину"></i>
|
||||
|
||||
@@ -31,6 +31,17 @@ use App\Core\View;
|
||||
.product-card { background: #f5f5f5; border-radius: 8px; overflow: hidden; }
|
||||
.product-card img { width: 100%; height: 200px; object-fit: cover; }
|
||||
.product-card .product-info { padding: 15px; }
|
||||
|
||||
/* Reviews Section */
|
||||
.product-reviews { margin: 40px 0; padding: 30px; background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
|
||||
.product-reviews h2 { color: #453227; margin-bottom: 25px; font-size: 26px; }
|
||||
.reviews-summary { background: #f8f9fa; padding: 25px; border-radius: 8px; margin-bottom: 30px; }
|
||||
.reviews-summary-rating { text-align: center; }
|
||||
.rating-number { font-size: 48px; font-weight: bold; color: #453227; margin-bottom: 10px; }
|
||||
.rating-stars { font-size: 24px; margin: 10px 0; }
|
||||
.rating-stars .star { color: #ddd; margin: 0 2px; }
|
||||
.rating-stars .star.filled { color: #ffc107; }
|
||||
.rating-count { color: #666; margin-top: 10px; font-size: 14px; }
|
||||
</style>
|
||||
|
||||
<main class="container">
|
||||
@@ -123,7 +134,7 @@ use App\Core\View;
|
||||
<?= nl2br(htmlspecialchars($product['description'] ?? 'Описание отсутствует')) ?>
|
||||
</p>
|
||||
|
||||
<?php if ($product['stock_quantity'] > 0): ?>
|
||||
<?php if (!$isAdmin && $product['stock_quantity'] > 0): ?>
|
||||
<div class="product__purchase">
|
||||
<div class="product__quantity">
|
||||
<button class="product__qty-btn minus">-</button>
|
||||
@@ -179,6 +190,52 @@ use App\Core\View;
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Reviews Section -->
|
||||
<section class="product-reviews">
|
||||
<h2>Отзывы о товаре</h2>
|
||||
|
||||
<div class="reviews-summary">
|
||||
<div class="reviews-summary-rating">
|
||||
<div class="rating-number"><?= number_format($product['rating'] ?? 0, 1) ?></div>
|
||||
<div class="rating-stars">
|
||||
<?php
|
||||
$avgRating = $product['rating'] ?? 0;
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$filled = $i <= round($avgRating);
|
||||
echo '<span class="star ' . ($filled ? 'filled' : '') . '">★</span>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="rating-count">
|
||||
Всего отзывов: <?= $product['review_count'] ?? 0 ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!$isAdmin): ?>
|
||||
<?php if ($userReview): ?>
|
||||
<!-- User has already reviewed, show edit form -->
|
||||
<?php
|
||||
$productId = $product['product_id'];
|
||||
include __DIR__ . '/_review_form.php';
|
||||
?>
|
||||
<?php else: ?>
|
||||
<!-- User hasn't reviewed yet, show new review form -->
|
||||
<?php
|
||||
$productId = $product['product_id'];
|
||||
$userReview = null;
|
||||
include __DIR__ . '/_review_form.php';
|
||||
?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Reviews List -->
|
||||
<?php
|
||||
$currentUserId = $user['id'] ?? null;
|
||||
include __DIR__ . '/_reviews_list.php';
|
||||
?>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -226,4 +283,177 @@ function buyNow(productId) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Review functionality
|
||||
$(document).ready(function() {
|
||||
// Submit review form
|
||||
$('#reviewForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const rating = parseInt($('#ratingValue').val());
|
||||
if (rating < 1 || rating > 5) {
|
||||
$('#ratingError').text('Пожалуйста, выберите оценку от 1 до 5 звезд').addClass('show');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
product_id: $('input[name="product_id"]').val(),
|
||||
rating: rating,
|
||||
comment: $('#reviewComment').val().trim()
|
||||
};
|
||||
|
||||
const reviewId = $('input[name="review_id"]').val();
|
||||
const url = reviewId ? `/reviews/${reviewId}` : '/reviews';
|
||||
const successMessage = reviewId ? 'Отзыв обновлен!' : 'Спасибо за ваш отзыв!';
|
||||
|
||||
$('#submitReviewBtn').prop('disabled', true).text('Отправка...');
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
$('#formMessage')
|
||||
.removeClass('error')
|
||||
.addClass('success')
|
||||
.text(successMessage)
|
||||
.show();
|
||||
|
||||
// Update product rating display
|
||||
if (result.product_rating !== undefined) {
|
||||
updateProductRating(result.product_rating, result.product_review_count);
|
||||
}
|
||||
|
||||
// Reload page after short delay to show updated reviews
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
$('#formMessage')
|
||||
.removeClass('success')
|
||||
.addClass('error')
|
||||
.text(result.message || 'Произошла ошибка')
|
||||
.show();
|
||||
$('#submitReviewBtn').prop('disabled', false).html('<i class="fas fa-paper-plane"></i> Отправить отзыв');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$('#formMessage')
|
||||
.removeClass('success')
|
||||
.addClass('error')
|
||||
.text('Ошибка соединения с сервером')
|
||||
.show();
|
||||
$('#submitReviewBtn').prop('disabled', false).html('<i class="fas fa-paper-plane"></i> Отправить отзыв');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Edit review button
|
||||
$(document).on('click', '.btn-edit-review', function() {
|
||||
const reviewId = $(this).data('review-id');
|
||||
const rating = $(this).data('rating');
|
||||
const comment = $(this).data('comment');
|
||||
|
||||
// Scroll to form
|
||||
$('html, body').animate({
|
||||
scrollTop: $('#reviewFormContainer').offset().top - 100
|
||||
}, 500);
|
||||
|
||||
// Set form values
|
||||
$('#ratingValue').val(rating);
|
||||
$('#reviewComment').val(comment);
|
||||
|
||||
// Update star display
|
||||
$('.star-input').each(function(index) {
|
||||
if (index < rating) {
|
||||
$(this).addClass('selected');
|
||||
} else {
|
||||
$(this).removeClass('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// Update rating text
|
||||
const ratingTexts = {
|
||||
1: '1 из 5 - Плохо',
|
||||
2: '2 из 5 - Неудовлетворительно',
|
||||
3: '3 из 5 - Нормально',
|
||||
4: '4 из 5 - Хорошо',
|
||||
5: '5 из 5 - Отлично!'
|
||||
};
|
||||
$('#ratingText').text(ratingTexts[rating]);
|
||||
|
||||
// Focus on comment field
|
||||
$('#reviewComment').focus();
|
||||
});
|
||||
|
||||
// Delete review button
|
||||
$(document).on('click', '.btn-delete-review', function() {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот отзыв?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewId = $(this).data('review-id');
|
||||
const $reviewItem = $(this).closest('.review-item');
|
||||
|
||||
$.ajax({
|
||||
url: `/reviews/${reviewId}/delete`,
|
||||
method: 'POST',
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
showNotification('Отзыв удален');
|
||||
|
||||
// Update product rating display
|
||||
if (result.product_rating !== undefined) {
|
||||
updateProductRating(result.product_rating, result.product_review_count);
|
||||
}
|
||||
|
||||
// Remove review item with animation
|
||||
$reviewItem.fadeOut(400, function() {
|
||||
$(this).remove();
|
||||
|
||||
// Check if there are no more reviews
|
||||
if ($('.review-item').length === 0) {
|
||||
$('#reviewsList').html('<div class="no-reviews"><p>Пока нет отзывов об этом товаре. Будьте первым!</p></div>');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
showNotification(result.message || 'Ошибка при удалении отзыва', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
showNotification('Ошибка соединения с сервером', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel edit button
|
||||
$(document).on('click', '#cancelEditBtn', function() {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
function updateProductRating(rating, count) {
|
||||
$('.rating-number').text(parseFloat(rating).toFixed(1));
|
||||
$('.rating-count').text('Всего отзывов: ' + count);
|
||||
|
||||
// Update stars in main product info
|
||||
const $productRating = $('.product__rating .stars');
|
||||
$productRating.empty();
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const filled = i <= Math.round(rating);
|
||||
$productRating.append(`<span class="star ${filled ? 'filled' : ''}">★</span>`);
|
||||
}
|
||||
$('.product__rating span:last-child').text(`(${count} отзывов)`);
|
||||
|
||||
// Update stars in reviews summary
|
||||
const $summaryStars = $('.reviews-summary-rating .rating-stars');
|
||||
$summaryStars.empty();
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const filled = i <= Math.round(rating);
|
||||
$summaryStars.append(`<span class="star ${filled ? 'filled' : ''}">★</span>`);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user