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:
kirill.khorkov
2026-01-06 17:04:09 +03:00
parent 547c561ed0
commit a4092adf2e
17 changed files with 1646 additions and 59 deletions

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>