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,298 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Review;
use App\Models\Product;
class ReviewController extends Controller
{
private Review $reviewModel;
private Product $productModel;
public function __construct()
{
$this->reviewModel = new Review();
$this->productModel = new Product();
}
/**
* Create a new review (POST /reviews)
*/
public function create(): void
{
// Require authentication
if (!$this->isAuthenticated()) {
$this->json([
'success' => false,
'message' => 'Требуется авторизация'
], 401);
return;
}
// Admins cannot leave reviews
if ($this->isAdmin()) {
$this->json([
'success' => false,
'message' => 'Администраторы не могут оставлять отзывы'
], 403);
return;
}
$productId = (int) $this->getPost('product_id', 0);
$rating = (int) $this->getPost('rating', 0);
$comment = trim($this->getPost('comment', ''));
$user = $this->getCurrentUser();
// Validate product ID
if ($productId <= 0) {
$this->json([
'success' => false,
'message' => 'Неверный ID товара'
], 400);
return;
}
// Check if product exists
$product = $this->productModel->find($productId);
if (!$product) {
$this->json([
'success' => false,
'message' => 'Товар не найден'
], 404);
return;
}
// Validate rating
if ($rating < 1 || $rating > 5) {
$this->json([
'success' => false,
'message' => 'Рейтинг должен быть от 1 до 5'
], 400);
return;
}
// Check if user can review
if (!$this->reviewModel->userCanReview($user['id'], $productId)) {
$this->json([
'success' => false,
'message' => 'Вы уже оставили отзыв на этот товар'
], 400);
return;
}
// Create review
$reviewId = $this->reviewModel->createReview([
'product_id' => $productId,
'user_id' => $user['id'],
'rating' => $rating,
'comment' => $comment,
'is_approved' => true
]);
if ($reviewId) {
// Get updated product info
$updatedProduct = $this->productModel->find($productId);
$this->json([
'success' => true,
'message' => 'Спасибо за ваш отзыв!',
'review_id' => $reviewId,
'product_rating' => $updatedProduct['rating'] ?? 0,
'product_review_count' => $updatedProduct['review_count'] ?? 0
]);
} else {
$this->json([
'success' => false,
'message' => 'Ошибка при создании отзыва'
], 500);
}
}
/**
* Update a review (POST /reviews/{id})
*/
public function update(int $id): void
{
// Require authentication
if (!$this->isAuthenticated()) {
$this->json([
'success' => false,
'message' => 'Требуется авторизация'
], 401);
return;
}
$user = $this->getCurrentUser();
$review = $this->reviewModel->find($id);
if (!$review) {
$this->json([
'success' => false,
'message' => 'Отзыв не найден'
], 404);
return;
}
// Check ownership (only owner or admin can update)
if ($review['user_id'] !== $user['id'] && !$this->isAdmin()) {
$this->json([
'success' => false,
'message' => 'У вас нет прав для редактирования этого отзыва'
], 403);
return;
}
$rating = (int) $this->getPost('rating', 0);
$comment = trim($this->getPost('comment', ''));
// Validate rating
if ($rating < 1 || $rating > 5) {
$this->json([
'success' => false,
'message' => 'Рейтинг должен быть от 1 до 5'
], 400);
return;
}
// Update review
$success = $this->reviewModel->updateReview($id, [
'rating' => $rating,
'comment' => $comment
]);
if ($success) {
// Get updated product info
$updatedProduct = $this->productModel->find($review['product_id']);
$this->json([
'success' => true,
'message' => 'Отзыв обновлен',
'product_rating' => $updatedProduct['rating'] ?? 0,
'product_review_count' => $updatedProduct['review_count'] ?? 0
]);
} else {
$this->json([
'success' => false,
'message' => 'Ошибка при обновлении отзыва'
], 500);
}
}
/**
* Delete a review (POST /reviews/{id}/delete)
*/
public function delete(int $id): void
{
// Require authentication
if (!$this->isAuthenticated()) {
$this->json([
'success' => false,
'message' => 'Требуется авторизация'
], 401);
return;
}
$user = $this->getCurrentUser();
$review = $this->reviewModel->find($id);
if (!$review) {
$this->json([
'success' => false,
'message' => 'Отзыв не найден'
], 404);
return;
}
// Check ownership (only owner or admin can delete)
if ($review['user_id'] !== $user['id'] && !$this->isAdmin()) {
$this->json([
'success' => false,
'message' => 'У вас нет прав для удаления этого отзыва'
], 403);
return;
}
$productId = $review['product_id'];
$success = $this->reviewModel->deleteReview($id);
if ($success) {
// Get updated product info
$updatedProduct = $this->productModel->find($productId);
$this->json([
'success' => true,
'message' => 'Отзыв удален',
'product_rating' => $updatedProduct['rating'] ?? 0,
'product_review_count' => $updatedProduct['review_count'] ?? 0
]);
} else {
$this->json([
'success' => false,
'message' => 'Ошибка при удалении отзыва'
], 500);
}
}
/**
* Get reviews for a product (GET /reviews/product/{id})
*/
public function getByProduct(int $productId): void
{
$product = $this->productModel->find($productId);
if (!$product) {
$this->json([
'success' => false,
'message' => 'Товар не найден'
], 404);
return;
}
$reviews = $this->reviewModel->getByProduct($productId, true);
$distribution = $this->reviewModel->getRatingDistribution($productId);
$this->json([
'success' => true,
'reviews' => $reviews,
'rating_distribution' => $distribution,
'average_rating' => $product['rating'] ?? 0,
'total_reviews' => $product['review_count'] ?? 0
]);
}
/**
* Toggle review approval (admin only) (POST /reviews/{id}/toggle-approval)
*/
public function toggleApproval(int $id): void
{
$this->requireAdmin();
$review = $this->reviewModel->find($id);
if (!$review) {
$this->json([
'success' => false,
'message' => 'Отзыв не найден'
], 404);
return;
}
$success = $this->reviewModel->toggleApproval($id);
if ($success) {
$updatedReview = $this->reviewModel->find($id);
$this->json([
'success' => true,
'message' => 'Статус отзыва изменен',
'is_approved' => $updatedReview['is_approved']
]);
} else {
$this->json([
'success' => false,
'message' => 'Ошибка при изменении статуса'
], 500);
}
}
}