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:
@@ -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();
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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()
|
||||
]);
|
||||
|
||||
298
app/Controllers/ReviewController.php
Normal file
298
app/Controllers/ReviewController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user