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

@@ -153,6 +153,28 @@ class AdminController extends Controller
$this->redirect('/admin/products?message=' . urlencode('Товар скрыт'));
}
public function removeProduct(int $id): void
{
$this->requireAdmin();
$product = $this->productModel->find($id);
if (!$product) {
$this->redirect('/admin/products?error=' . urlencode('Товар не найден'));
return;
}
// Удаляем только если товара нет на складе
if ($product['stock_quantity'] > 0) {
$this->redirect('/admin/products?error=' . urlencode('Нельзя удалить товар, который есть на складе'));
return;
}
// Полное удаление из БД
$this->productModel->delete($id);
$this->redirect('/admin/products?message=' . urlencode('Товар удален из базы данных'));
}
public function categories(): void
{
$this->requireAdmin();

View File

@@ -21,6 +21,11 @@ class CartController extends Controller
{
$this->requireAuth();
if ($this->isAdmin()) {
$this->redirect('/catalog');
return;
}
$user = $this->getCurrentUser();
$cartItems = $this->cartModel->getUserCart($user['id']);
$totals = $this->cartModel->getCartTotal($user['id']);
@@ -43,6 +48,14 @@ class CartController extends Controller
return;
}
if ($this->isAdmin()) {
$this->json([
'success' => false,
'message' => 'Доступ запрещен'
]);
return;
}
$productId = (int) $this->getPost('product_id', 0);
$quantity = (int) $this->getPost('quantity', 1);
$userId = $this->getCurrentUser()['id'];
@@ -104,6 +117,14 @@ class CartController extends Controller
return;
}
if ($this->isAdmin()) {
$this->json([
'success' => false,
'message' => 'Доступ запрещен'
]);
return;
}
$productId = (int) $this->getPost('product_id', 0);
$quantity = (int) $this->getPost('quantity', 1);
$userId = $this->getCurrentUser()['id'];
@@ -154,6 +175,14 @@ class CartController extends Controller
return;
}
if ($this->isAdmin()) {
$this->json([
'success' => false,
'message' => 'Доступ запрещен'
]);
return;
}
$productId = (int) $this->getPost('product_id', 0);
$userId = $this->getCurrentUser()['id'];

View File

@@ -21,6 +21,11 @@ class OrderController extends Controller
{
$this->requireAuth();
if ($this->isAdmin()) {
$this->redirect('/catalog');
return;
}
$user = $this->getCurrentUser();
$cartItems = $this->cartModel->getUserCart($user['id']);
$totals = $this->cartModel->getCartTotal($user['id']);
@@ -43,6 +48,14 @@ class OrderController extends Controller
return;
}
if ($this->isAdmin()) {
$this->json([
'success' => false,
'message' => 'Доступ запрещен'
]);
return;
}
$user = $this->getCurrentUser();
$cartItems = $this->cartModel->getUserCart($user['id']);

View File

@@ -5,16 +5,19 @@ namespace App\Controllers;
use App\Core\Controller;
use App\Models\Product;
use App\Models\Category;
use App\Models\Review;
class ProductController extends Controller
{
private Product $productModel;
private Category $categoryModel;
private Review $reviewModel;
public function __construct()
{
$this->productModel = new Product();
$this->categoryModel = new Category();
$this->reviewModel = new Review();
}
public function catalog(): void
@@ -33,10 +36,12 @@ class ProductController extends Controller
'materials' => $this->getQuery('materials', [])
];
$showAll = $isAdmin && $this->getQuery('show_all') === '1';
// Для админа по умолчанию показываем все товары (включая недоступные)
// Для обычных пользователей - только доступные
$showAll = $isAdmin && $this->getQuery('show_all') !== '0';
$categories = $this->categoryModel->getActive();
$products = $showAll
$products = ($isAdmin && $showAll)
? $this->productModel->getAllForAdmin(true)
: $this->productModel->getAvailable($filters);
@@ -80,10 +85,22 @@ class ProductController extends Controller
$product['category_id']
);
// Load reviews
$reviews = $this->reviewModel->getByProduct($id, true);
// Check if current user has already reviewed
$user = $this->getCurrentUser();
$userReview = null;
if ($user && !$this->isAdmin()) {
$userReview = $this->reviewModel->getByUser($user['id'], $id);
}
$this->view('products/show', [
'product' => $product,
'similarProducts' => $similarProducts,
'user' => $this->getCurrentUser(),
'reviews' => $reviews,
'userReview' => $userReview,
'user' => $user,
'isLoggedIn' => true,
'isAdmin' => $this->isAdmin()
]);

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);
}
}
}