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('Товар скрыт'));
|
$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
|
public function categories(): void
|
||||||
{
|
{
|
||||||
$this->requireAdmin();
|
$this->requireAdmin();
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class CartController extends Controller
|
|||||||
{
|
{
|
||||||
$this->requireAuth();
|
$this->requireAuth();
|
||||||
|
|
||||||
|
if ($this->isAdmin()) {
|
||||||
|
$this->redirect('/catalog');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$user = $this->getCurrentUser();
|
$user = $this->getCurrentUser();
|
||||||
$cartItems = $this->cartModel->getUserCart($user['id']);
|
$cartItems = $this->cartModel->getUserCart($user['id']);
|
||||||
$totals = $this->cartModel->getCartTotal($user['id']);
|
$totals = $this->cartModel->getCartTotal($user['id']);
|
||||||
@@ -43,6 +48,14 @@ class CartController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->isAdmin()) {
|
||||||
|
$this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Доступ запрещен'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$productId = (int) $this->getPost('product_id', 0);
|
$productId = (int) $this->getPost('product_id', 0);
|
||||||
$quantity = (int) $this->getPost('quantity', 1);
|
$quantity = (int) $this->getPost('quantity', 1);
|
||||||
$userId = $this->getCurrentUser()['id'];
|
$userId = $this->getCurrentUser()['id'];
|
||||||
@@ -104,6 +117,14 @@ class CartController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->isAdmin()) {
|
||||||
|
$this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Доступ запрещен'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$productId = (int) $this->getPost('product_id', 0);
|
$productId = (int) $this->getPost('product_id', 0);
|
||||||
$quantity = (int) $this->getPost('quantity', 1);
|
$quantity = (int) $this->getPost('quantity', 1);
|
||||||
$userId = $this->getCurrentUser()['id'];
|
$userId = $this->getCurrentUser()['id'];
|
||||||
@@ -154,6 +175,14 @@ class CartController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->isAdmin()) {
|
||||||
|
$this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Доступ запрещен'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$productId = (int) $this->getPost('product_id', 0);
|
$productId = (int) $this->getPost('product_id', 0);
|
||||||
$userId = $this->getCurrentUser()['id'];
|
$userId = $this->getCurrentUser()['id'];
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class OrderController extends Controller
|
|||||||
{
|
{
|
||||||
$this->requireAuth();
|
$this->requireAuth();
|
||||||
|
|
||||||
|
if ($this->isAdmin()) {
|
||||||
|
$this->redirect('/catalog');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$user = $this->getCurrentUser();
|
$user = $this->getCurrentUser();
|
||||||
$cartItems = $this->cartModel->getUserCart($user['id']);
|
$cartItems = $this->cartModel->getUserCart($user['id']);
|
||||||
$totals = $this->cartModel->getCartTotal($user['id']);
|
$totals = $this->cartModel->getCartTotal($user['id']);
|
||||||
@@ -43,6 +48,14 @@ class OrderController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->isAdmin()) {
|
||||||
|
$this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Доступ запрещен'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$user = $this->getCurrentUser();
|
$user = $this->getCurrentUser();
|
||||||
$cartItems = $this->cartModel->getUserCart($user['id']);
|
$cartItems = $this->cartModel->getUserCart($user['id']);
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ namespace App\Controllers;
|
|||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Models\Review;
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
private Product $productModel;
|
private Product $productModel;
|
||||||
private Category $categoryModel;
|
private Category $categoryModel;
|
||||||
|
private Review $reviewModel;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->productModel = new Product();
|
$this->productModel = new Product();
|
||||||
$this->categoryModel = new Category();
|
$this->categoryModel = new Category();
|
||||||
|
$this->reviewModel = new Review();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function catalog(): void
|
public function catalog(): void
|
||||||
@@ -33,10 +36,12 @@ class ProductController extends Controller
|
|||||||
'materials' => $this->getQuery('materials', [])
|
'materials' => $this->getQuery('materials', [])
|
||||||
];
|
];
|
||||||
|
|
||||||
$showAll = $isAdmin && $this->getQuery('show_all') === '1';
|
// Для админа по умолчанию показываем все товары (включая недоступные)
|
||||||
|
// Для обычных пользователей - только доступные
|
||||||
|
$showAll = $isAdmin && $this->getQuery('show_all') !== '0';
|
||||||
|
|
||||||
$categories = $this->categoryModel->getActive();
|
$categories = $this->categoryModel->getActive();
|
||||||
$products = $showAll
|
$products = ($isAdmin && $showAll)
|
||||||
? $this->productModel->getAllForAdmin(true)
|
? $this->productModel->getAllForAdmin(true)
|
||||||
: $this->productModel->getAvailable($filters);
|
: $this->productModel->getAvailable($filters);
|
||||||
|
|
||||||
@@ -80,10 +85,22 @@ class ProductController extends Controller
|
|||||||
$product['category_id']
|
$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', [
|
$this->view('products/show', [
|
||||||
'product' => $product,
|
'product' => $product,
|
||||||
'similarProducts' => $similarProducts,
|
'similarProducts' => $similarProducts,
|
||||||
'user' => $this->getCurrentUser(),
|
'reviews' => $reviews,
|
||||||
|
'userReview' => $userReview,
|
||||||
|
'user' => $user,
|
||||||
'isLoggedIn' => true,
|
'isLoggedIn' => true,
|
||||||
'isAdmin' => $this->isAdmin()
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ class Product extends Model
|
|||||||
|
|
||||||
public function findWithCategory(int $id): ?array
|
public function findWithCategory(int $id): ?array
|
||||||
{
|
{
|
||||||
$sql = "SELECT p.*, c.name as category_name, c.slug as category_slug
|
$sql = "SELECT p.*, c.name as category_name, c.slug as category_slug,
|
||||||
|
COALESCE(p.rating, 0) as rating,
|
||||||
|
COALESCE(p.review_count, 0) as review_count
|
||||||
FROM {$this->table} p
|
FROM {$this->table} p
|
||||||
LEFT JOIN categories c ON p.category_id = c.category_id
|
LEFT JOIN categories c ON p.category_id = c.category_id
|
||||||
WHERE p.product_id = ?";
|
WHERE p.product_id = ?";
|
||||||
@@ -20,7 +22,9 @@ class Product extends Model
|
|||||||
|
|
||||||
public function getAvailable(array $filters = [], int $limit = 50): array
|
public function getAvailable(array $filters = [], int $limit = 50): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT p.*, c.name as category_name
|
$sql = "SELECT p.*, c.name as category_name,
|
||||||
|
COALESCE(p.rating, 0) as rating,
|
||||||
|
COALESCE(p.review_count, 0) as review_count
|
||||||
FROM {$this->table} p
|
FROM {$this->table} p
|
||||||
LEFT JOIN categories c ON p.category_id = c.category_id
|
LEFT JOIN categories c ON p.category_id = c.category_id
|
||||||
WHERE p.is_available = TRUE";
|
WHERE p.is_available = TRUE";
|
||||||
@@ -67,7 +71,9 @@ class Product extends Model
|
|||||||
|
|
||||||
public function getAllForAdmin(bool $showAll = true): array
|
public function getAllForAdmin(bool $showAll = true): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT p.*, c.name as category_name
|
$sql = "SELECT p.*, c.name as category_name,
|
||||||
|
COALESCE(p.rating, 0) as rating,
|
||||||
|
COALESCE(p.review_count, 0) as review_count
|
||||||
FROM {$this->table} p
|
FROM {$this->table} p
|
||||||
LEFT JOIN categories c ON p.category_id = c.category_id";
|
LEFT JOIN categories c ON p.category_id = c.category_id";
|
||||||
|
|
||||||
@@ -82,7 +88,10 @@ class Product extends Model
|
|||||||
|
|
||||||
public function getSimilar(int $productId, int $categoryId, int $limit = 3): array
|
public function getSimilar(int $productId, int $categoryId, int $limit = 3): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT * FROM {$this->table}
|
$sql = "SELECT *,
|
||||||
|
COALESCE(rating, 0) as rating,
|
||||||
|
COALESCE(review_count, 0) as review_count
|
||||||
|
FROM {$this->table}
|
||||||
WHERE category_id = ?
|
WHERE category_id = ?
|
||||||
AND product_id != ?
|
AND product_id != ?
|
||||||
AND is_available = TRUE
|
AND is_available = TRUE
|
||||||
|
|||||||
246
app/Models/Review.php
Normal file
246
app/Models/Review.php
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Model;
|
||||||
|
|
||||||
|
class Review extends Model
|
||||||
|
{
|
||||||
|
protected string $table = 'reviews';
|
||||||
|
protected string $primaryKey = 'review_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new review
|
||||||
|
*/
|
||||||
|
public function createReview(array $data): ?int
|
||||||
|
{
|
||||||
|
// Check if user already reviewed this product
|
||||||
|
$existing = $this->getByUser($data['user_id'], $data['product_id']);
|
||||||
|
if ($existing) {
|
||||||
|
return null; // User already reviewed this product
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->create([
|
||||||
|
'product_id' => $data['product_id'],
|
||||||
|
'user_id' => $data['user_id'],
|
||||||
|
'rating' => $data['rating'],
|
||||||
|
'comment' => $data['comment'] ?? null,
|
||||||
|
'is_approved' => $data['is_approved'] ?? true
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all reviews for a product
|
||||||
|
*/
|
||||||
|
public function getByProduct(int $productId, bool $approvedOnly = true): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT r.*, u.full_name, u.email
|
||||||
|
FROM {$this->table} r
|
||||||
|
INNER JOIN users u ON r.user_id = u.user_id";
|
||||||
|
|
||||||
|
if ($approvedOnly) {
|
||||||
|
$sql .= " WHERE r.product_id = ? AND r.is_approved = TRUE";
|
||||||
|
} else {
|
||||||
|
$sql .= " WHERE r.product_id = ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY r.created_at DESC";
|
||||||
|
|
||||||
|
return $this->query($sql, [$productId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get review by specific user for a product
|
||||||
|
*/
|
||||||
|
public function getByUser(int $userId, int $productId): ?array
|
||||||
|
{
|
||||||
|
$sql = "SELECT * FROM {$this->table}
|
||||||
|
WHERE user_id = ? AND product_id = ?";
|
||||||
|
return $this->queryOne($sql, [$userId, $productId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get review with user details
|
||||||
|
*/
|
||||||
|
public function findWithUser(int $reviewId): ?array
|
||||||
|
{
|
||||||
|
$sql = "SELECT r.*, u.full_name, u.email
|
||||||
|
FROM {$this->table} r
|
||||||
|
INNER JOIN users u ON r.user_id = u.user_id
|
||||||
|
WHERE r.review_id = ?";
|
||||||
|
return $this->queryOne($sql, [$reviewId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a review
|
||||||
|
*/
|
||||||
|
public function updateReview(int $reviewId, array $data): bool
|
||||||
|
{
|
||||||
|
$updateData = [];
|
||||||
|
|
||||||
|
if (isset($data['rating'])) {
|
||||||
|
$updateData['rating'] = $data['rating'];
|
||||||
|
}
|
||||||
|
if (isset($data['comment'])) {
|
||||||
|
$updateData['comment'] = $data['comment'];
|
||||||
|
}
|
||||||
|
if (isset($data['is_approved'])) {
|
||||||
|
$updateData['is_approved'] = $data['is_approved'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updateData)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->update($reviewId, $updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a review
|
||||||
|
*/
|
||||||
|
public function deleteReview(int $reviewId): bool
|
||||||
|
{
|
||||||
|
return $this->delete($reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get average rating for a product
|
||||||
|
*/
|
||||||
|
public function getAverageRating(int $productId): float
|
||||||
|
{
|
||||||
|
$sql = "SELECT COALESCE(AVG(rating), 0.00) as avg_rating
|
||||||
|
FROM {$this->table}
|
||||||
|
WHERE product_id = ? AND is_approved = TRUE";
|
||||||
|
$result = $this->queryOne($sql, [$productId]);
|
||||||
|
return round((float)$result['avg_rating'], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get review count for a product
|
||||||
|
*/
|
||||||
|
public function getReviewCount(int $productId): int
|
||||||
|
{
|
||||||
|
return $this->count([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'is_approved' => true
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update product's rating and review count
|
||||||
|
* This is called automatically by database triggers,
|
||||||
|
* but can also be called manually if needed
|
||||||
|
*/
|
||||||
|
public function updateProductRating(int $productId): void
|
||||||
|
{
|
||||||
|
// Call the PostgreSQL function
|
||||||
|
$sql = "SELECT update_product_rating(?)";
|
||||||
|
$this->execute($sql, [$productId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rating distribution for a product (useful for charts)
|
||||||
|
*/
|
||||||
|
public function getRatingDistribution(int $productId): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT rating, COUNT(*) as count
|
||||||
|
FROM {$this->table}
|
||||||
|
WHERE product_id = ? AND is_approved = TRUE
|
||||||
|
GROUP BY rating
|
||||||
|
ORDER BY rating DESC";
|
||||||
|
|
||||||
|
$results = $this->query($sql, [$productId]);
|
||||||
|
|
||||||
|
// Initialize all ratings with 0
|
||||||
|
$distribution = [
|
||||||
|
5 => 0,
|
||||||
|
4 => 0,
|
||||||
|
3 => 0,
|
||||||
|
2 => 0,
|
||||||
|
1 => 0
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fill in actual counts
|
||||||
|
foreach ($results as $row) {
|
||||||
|
$distribution[$row['rating']] = (int)$row['count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $distribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent reviews across all products (for admin dashboard)
|
||||||
|
*/
|
||||||
|
public function getRecent(int $limit = 10, bool $approvedOnly = false): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT r.*, u.full_name, u.email, p.name as product_name
|
||||||
|
FROM {$this->table} r
|
||||||
|
INNER JOIN users u ON r.user_id = u.user_id
|
||||||
|
INNER JOIN products p ON r.product_id = p.product_id";
|
||||||
|
|
||||||
|
if ($approvedOnly) {
|
||||||
|
$sql .= " WHERE r.is_approved = TRUE";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY r.created_at DESC LIMIT ?";
|
||||||
|
|
||||||
|
return $this->query($sql, [$limit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can review (has purchased the product)
|
||||||
|
* This is optional - you might want to allow reviews only from buyers
|
||||||
|
*/
|
||||||
|
public function userCanReview(int $userId, int $productId): bool
|
||||||
|
{
|
||||||
|
// Check if user already reviewed
|
||||||
|
$existing = $this->getByUser($userId, $productId);
|
||||||
|
if ($existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Check if user has purchased this product
|
||||||
|
// For now, we'll allow any authenticated user to review
|
||||||
|
return true;
|
||||||
|
|
||||||
|
/* Uncomment this to require purchase before review:
|
||||||
|
$sql = "SELECT COUNT(*) as count
|
||||||
|
FROM order_items oi
|
||||||
|
INNER JOIN orders o ON oi.order_id = o.order_id
|
||||||
|
WHERE o.user_id = ? AND oi.product_id = ? AND o.status = 'completed'";
|
||||||
|
$result = $this->queryOne($sql, [$userId, $productId]);
|
||||||
|
return $result && $result['count'] > 0;
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle review approval status (admin function)
|
||||||
|
*/
|
||||||
|
public function toggleApproval(int $reviewId): bool
|
||||||
|
{
|
||||||
|
$review = $this->find($reviewId);
|
||||||
|
if (!$review) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->update($reviewId, [
|
||||||
|
'is_approved' => !$review['is_approved']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all reviews by a specific user
|
||||||
|
*/
|
||||||
|
public function getUserReviews(int $userId, int $limit = 50): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT r.*, p.name as product_name, p.image_url as product_image
|
||||||
|
FROM {$this->table} r
|
||||||
|
INNER JOIN products p ON r.product_id = p.product_id
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
LIMIT ?";
|
||||||
|
|
||||||
|
return $this->query($sql, [$userId, $limit]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -66,6 +66,14 @@
|
|||||||
<i class="fas fa-eye-slash"></i>
|
<i class="fas fa-eye-slash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -155,6 +155,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn primary-btn">Задать вопрос</button>
|
<a href="#footer" class="btn primary-btn">Задать вопрос</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -38,7 +38,17 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="notification" class="notification"></div>
|
<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>
|
<main>
|
||||||
<?= $content ?>
|
<?= $content ?>
|
||||||
@@ -55,6 +65,68 @@
|
|||||||
setTimeout(function() { notification.removeClass('show'); }, 3000);
|
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() {
|
$(document).ready(function() {
|
||||||
$.get('/cart/count', function(response) {
|
$.get('/cart/count', function(response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|||||||
@@ -3,22 +3,30 @@ $isLoggedIn = $isLoggedIn ?? \App\Core\View::isAuthenticated();
|
|||||||
$isAdmin = $isAdmin ?? \App\Core\View::isAdmin();
|
$isAdmin = $isAdmin ?? \App\Core\View::isAdmin();
|
||||||
$user = $user ?? \App\Core\View::currentUser();
|
$user = $user ?? \App\Core\View::currentUser();
|
||||||
?>
|
?>
|
||||||
|
<style>
|
||||||
|
#catalogMenu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#catalogDropdown.active #catalogMenu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header__top">
|
<div class="header__top">
|
||||||
<div class="container header__top-content">
|
<div class="container header__top-content">
|
||||||
<a href="/" class="logo">AETERNA</a>
|
<a href="/" class="logo">AETERNA</a>
|
||||||
|
|
||||||
<div class="search-catalog">
|
<div class="search-catalog">
|
||||||
<div class="catalog-dropdown">
|
<div class="catalog-dropdown" id="catalogDropdown">
|
||||||
Все категории <span>▼</span>
|
Все категории <span>▼</span>
|
||||||
<div class="catalog-dropdown__menu">
|
<div class="catalog-dropdown__menu" id="catalogMenu">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/catalog">Все товары</a></li>
|
<li><a href="/catalog">Все товары</a></li>
|
||||||
<li><a href="/catalog?category=1">Диваны</a></li>
|
<?php if (!empty($categories)): ?>
|
||||||
<li><a href="/catalog?category=2">Кровати</a></li>
|
<?php foreach ($categories as $category): ?>
|
||||||
<li><a href="/catalog?category=3">Шкафы</a></li>
|
<li><a href="/catalog?category=<?= $category['category_id'] ?>"><?= htmlspecialchars($category['name']) ?></a></li>
|
||||||
<li><a href="/catalog?category=4">Стулья</a></li>
|
<?php endforeach; ?>
|
||||||
<li><a href="/catalog?category=5">Столы</a></li>
|
<?php endif; ?>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,48 +42,16 @@ $user = $user ?? \App\Core\View::currentUser();
|
|||||||
|
|
||||||
<div class="header__icons--top">
|
<div class="header__icons--top">
|
||||||
<?php if ($isLoggedIn): ?>
|
<?php if ($isLoggedIn): ?>
|
||||||
|
<?php if (!$isAdmin): ?>
|
||||||
<a href="/cart" class="icon cart-icon">
|
<a href="/cart" class="icon cart-icon">
|
||||||
<i class="fas fa-shopping-cart"></i>
|
<i class="fas fa-shopping-cart"></i>
|
||||||
<span class="cart-count">0</span>
|
<span class="cart-count">0</span>
|
||||||
</a>
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="user-profile-dropdown">
|
<a href="/logout" style="font-size: 14px; color: #666; text-decoration: none; margin-left: 15px;">
|
||||||
<div class="user-profile-toggle">
|
<i class="fas fa-sign-out-alt"></i> Выйти
|
||||||
<div class="user-avatar">
|
</a>
|
||||||
<?= !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>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<a href="/login" class="icon"><i class="far fa-user"></i></a>
|
<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>
|
<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-card img { width: 100%; height: 200px; object-fit: cover; }
|
||||||
.product-info { padding: 15px; }
|
.product-info { padding: 15px; }
|
||||||
.product-info .name { font-weight: bold; color: #453227; margin-bottom: 5px; }
|
.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; }
|
.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 { 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; }
|
.add-to-cart-btn:hover { background: #453227; color: white; }
|
||||||
@@ -47,12 +52,21 @@ use App\Core\View;
|
|||||||
<h3 style="margin-bottom: 15px; color: #453227;">
|
<h3 style="margin-bottom: 15px; color: #453227;">
|
||||||
<i class="fas fa-user-shield"></i> Панель управления каталогом
|
<i class="fas fa-user-shield"></i> Панель управления каталогом
|
||||||
</h3>
|
</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" 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/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/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>
|
<a href="/admin/orders" class="admin-btn"><i class="fas fa-shopping-cart"></i> Заказы</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -160,9 +174,23 @@ use App\Core\View;
|
|||||||
alt="<?= htmlspecialchars($product['name']) ?>">
|
alt="<?= htmlspecialchars($product['name']) ?>">
|
||||||
<div class="product-info">
|
<div class="product-info">
|
||||||
<div class="name"><?= htmlspecialchars($product['name']) ?></div>
|
<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 class="price"><?= View::formatPrice($product['price']) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<?php if ($product['is_available']): ?>
|
<?php if (!$isAdmin && $product['is_available']): ?>
|
||||||
<i class="fas fa-shopping-cart add-to-cart-btn"
|
<i class="fas fa-shopping-cart add-to-cart-btn"
|
||||||
onclick="event.stopPropagation(); addToCart(<?= $product['product_id'] ?>, '<?= addslashes($product['name']) ?>')"
|
onclick="event.stopPropagation(); addToCart(<?= $product['product_id'] ?>, '<?= addslashes($product['name']) ?>')"
|
||||||
title="Добавить в корзину"></i>
|
title="Добавить в корзину"></i>
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ use App\Core\View;
|
|||||||
.product-card { background: #f5f5f5; border-radius: 8px; overflow: hidden; }
|
.product-card { background: #f5f5f5; border-radius: 8px; overflow: hidden; }
|
||||||
.product-card img { width: 100%; height: 200px; object-fit: cover; }
|
.product-card img { width: 100%; height: 200px; object-fit: cover; }
|
||||||
.product-card .product-info { padding: 15px; }
|
.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>
|
</style>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -123,7 +134,7 @@ use App\Core\View;
|
|||||||
<?= nl2br(htmlspecialchars($product['description'] ?? 'Описание отсутствует')) ?>
|
<?= nl2br(htmlspecialchars($product['description'] ?? 'Описание отсутствует')) ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<?php if ($product['stock_quantity'] > 0): ?>
|
<?php if (!$isAdmin && $product['stock_quantity'] > 0): ?>
|
||||||
<div class="product__purchase">
|
<div class="product__purchase">
|
||||||
<div class="product__quantity">
|
<div class="product__quantity">
|
||||||
<button class="product__qty-btn minus">-</button>
|
<button class="product__qty-btn minus">-</button>
|
||||||
@@ -179,6 +190,52 @@ use App\Core\View;
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?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>
|
</main>
|
||||||
|
|
||||||
<script>
|
<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>
|
</script>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ $router->post('/admin/products/add', 'AdminController', 'storeProduct');
|
|||||||
$router->get('/admin/products/edit/{id}', 'AdminController', 'editProduct');
|
$router->get('/admin/products/edit/{id}', 'AdminController', 'editProduct');
|
||||||
$router->post('/admin/products/edit/{id}', 'AdminController', 'updateProduct');
|
$router->post('/admin/products/edit/{id}', 'AdminController', 'updateProduct');
|
||||||
$router->post('/admin/products/delete/{id}', 'AdminController', 'deleteProduct');
|
$router->post('/admin/products/delete/{id}', 'AdminController', 'deleteProduct');
|
||||||
|
$router->post('/admin/products/remove/{id}', 'AdminController', 'removeProduct');
|
||||||
|
|
||||||
$router->get('/admin/categories', 'AdminController', 'categories');
|
$router->get('/admin/categories', 'AdminController', 'categories');
|
||||||
$router->get('/admin/categories/add', 'AdminController', 'addCategory');
|
$router->get('/admin/categories/add', 'AdminController', 'addCategory');
|
||||||
@@ -46,3 +47,9 @@ $router->get('/admin/orders/{id}', 'AdminController', 'orderDetails');
|
|||||||
$router->post('/admin/orders/{id}/status', 'AdminController', 'updateOrderStatus');
|
$router->post('/admin/orders/{id}/status', 'AdminController', 'updateOrderStatus');
|
||||||
|
|
||||||
$router->get('/admin/users', 'AdminController', 'users');
|
$router->get('/admin/users', 'AdminController', 'users');
|
||||||
|
|
||||||
|
$router->post('/reviews', 'ReviewController', 'create');
|
||||||
|
$router->post('/reviews/{id}', 'ReviewController', 'update');
|
||||||
|
$router->post('/reviews/{id}/delete', 'ReviewController', 'delete');
|
||||||
|
$router->get('/reviews/product/{id}', 'ReviewController', 'getByProduct');
|
||||||
|
$router->post('/reviews/{id}/toggle-approval', 'ReviewController', 'toggleApproval');
|
||||||
|
|||||||
@@ -148,19 +148,65 @@ p, li, span {
|
|||||||
.flex-center(10px);
|
.flex-center(10px);
|
||||||
width: 200px;
|
width: 200px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
&__menu {
|
&__menu {
|
||||||
.menu-base();
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 250px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 15px;
|
||||||
|
z-index: 10000;
|
||||||
|
margin-top: 5px;
|
||||||
|
display: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
list-style: none;
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
&:hover { color: @color-accent; }
|
a {
|
||||||
|
display: block;
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 5px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
&:hover {
|
||||||
|
color: @color-accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
a {
|
||||||
|
color: @color-accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&:hover &__menu { display: block; }
|
|
||||||
|
&:hover &__menu,
|
||||||
|
&.active &__menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
@@ -943,6 +989,67 @@ p, li, span {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.star {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #ddd;
|
||||||
|
|
||||||
|
&.filled {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global rating styles
|
||||||
|
.rating-stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.star {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ddd;
|
||||||
|
|
||||||
|
&.filled {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review rating styles for product cards
|
||||||
|
.product-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
|
||||||
|
.star {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ddd;
|
||||||
|
|
||||||
|
&.filled {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.product__color-selector {
|
.product__color-selector {
|
||||||
|
|||||||
Reference in New Issue
Block a user