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:
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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user