From c032e5e52639d9402c778fcb1b89731d5e95df67 Mon Sep 17 00:00:00 2001 From: ValayaDase Date: Tue, 23 Jun 2026 17:33:37 +0530 Subject: [PATCH 1/3] review feature has been added --- backend/controllers/productController.js | 17 +- backend/controllers/reviewController.js | 254 +++++++++++ backend/models/Review.js | 13 + backend/routes/chatRoutes.js | 3 +- backend/routes/productRoutes.js | 22 +- backend/schema.sql | 48 ++- backend/seedProducts.js | 44 ++ frontend/product.html | 99 +++-- frontend/scripts/auth.js | 61 +-- frontend/scripts/product-cards-home.js | 2 +- frontend/scripts/product-render.js | 60 ++- frontend/scripts/product-reviews.js | 524 ++++++++++++----------- frontend/scripts/product.js | 21 +- frontend/scripts/shop.js | 21 + frontend/scripts/utils.js | 26 +- frontend/styles/product-card.css | 20 + frontend/styles/product.css | 90 +++- 17 files changed, 961 insertions(+), 364 deletions(-) create mode 100644 backend/controllers/reviewController.js create mode 100644 backend/models/Review.js create mode 100644 backend/seedProducts.js diff --git a/backend/controllers/productController.js b/backend/controllers/productController.js index fcfce66..accae20 100644 --- a/backend/controllers/productController.js +++ b/backend/controllers/productController.js @@ -89,7 +89,9 @@ const getProducts = async (req, res) => { image, category, stock, - featured + featured, + rating, + num_reviews ${baseQuery} ORDER BY id DESC LIMIT ? @@ -194,7 +196,9 @@ const getSingleProduct = async (req, res) => { image, category, stock, - featured + featured, + rating, + num_reviews FROM products WHERE id = ? `; @@ -409,7 +413,7 @@ const DeleteeProduct = async (req, res) => { }); } - const query = "DeleteE FROM products WHERE id = ?"; + const query = "DELETE FROM products WHERE id = ?"; try { const [result] = await db.query(query, [id]); @@ -423,7 +427,7 @@ const DeleteeProduct = async (req, res) => { res.status(200).json({ success: true, - message: "Product Deleteed successfully" + message: "Product deleted successfully" }); } catch (error) { console.error(error); @@ -457,5 +461,6 @@ module.exports = { getSingleProduct, createProduct, updateProduct, - DeleteeProduct -}; \ No newline at end of file + DeleteeProduct, + getProductSuggestions +}; diff --git a/backend/controllers/reviewController.js b/backend/controllers/reviewController.js new file mode 100644 index 0000000..c6691cd --- /dev/null +++ b/backend/controllers/reviewController.js @@ -0,0 +1,254 @@ +const db = require("../config/db"); +const Review = require("../models/Review"); +const { + safeArray, + safeInteger, + sanitizeString +} = require("../utils/helpers"); + +async function productExists(productId) { + const [products] = await db.query( + "SELECT id FROM products WHERE id = ? LIMIT 1", + [productId] + ); + + return safeArray(products).length > 0; +} + +async function refreshProductReviewStats(productId, connection = db) { + const [stats] = await connection.query( + ` + SELECT + COALESCE(ROUND(AVG(rating), 2), 0) AS average_rating, + COUNT(*) AS review_count + FROM reviews + WHERE product_id = ? + `, + [productId] + ); + + const averageRating = Number(stats?.[0]?.average_rating || 0); + const reviewCount = Number(stats?.[0]?.review_count || 0); + + await connection.query( + ` + UPDATE products + SET rating = ?, num_reviews = ? + WHERE id = ? + `, + [averageRating, reviewCount, productId] + ); + + return { + averageRating, + reviewCount + }; +} + +const getProductReviews = async (req, res) => { + const productId = safeInteger(req.params.id); + + if (!productId) { + return res.status(400).json({ + success: false, + message: "Invalid product ID" + }); + } + + try { + if (!(await productExists(productId))) { + return res.status(404).json({ + success: false, + message: "Product not found" + }); + } + + const [reviews] = await db.query( + ` + SELECT + r.id, + r.product_id, + r.user_id, + u.name AS user_name, + r.rating, + r.comment, + r.created_at + FROM reviews r + JOIN users u ON u.id = r.user_id + WHERE r.product_id = ? + ORDER BY r.created_at DESC, r.id DESC + `, + [productId] + ); + + const [stats] = await db.query( + ` + SELECT + rating AS average_rating, + num_reviews AS review_count + FROM products + WHERE id = ? + LIMIT 1 + `, + [productId] + ); + + return res.status(200).json({ + success: true, + message: "Reviews fetched successfully", + averageRating: Number(stats?.[0]?.average_rating || 0), + reviewCount: Number(stats?.[0]?.review_count || 0), + reviews: safeArray(reviews).map((review) => new Review(review)) + }); + } catch (error) { + console.error("GET PRODUCT REVIEWS ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to fetch reviews" + }); + } +}; + +const createProductReview = async (req, res) => { + const productId = safeInteger(req.params.id); + const userId = safeInteger(req.user?.id); + const rating = safeInteger(req.body.rating); + const comment = sanitizeString(req.body.comment); + + if (!productId) { + return res.status(400).json({ + success: false, + message: "Invalid product ID" + }); + } + + if (!userId) { + return res.status(401).json({ + success: false, + message: "Authentication required" + }); + } + + if (rating < 1 || rating > 5) { + return res.status(400).json({ + success: false, + message: "Rating must be between 1 and 5" + }); + } + + if (!comment || comment.length < 3 || comment.length > 1000) { + return res.status(400).json({ + success: false, + message: "Review comment must be between 3 and 1000 characters" + }); + } + + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [products] = await connection.query( + "SELECT id FROM products WHERE id = ? LIMIT 1", + [productId] + ); + + if (!safeArray(products).length) { + await connection.rollback(); + + return res.status(404).json({ + success: false, + message: "Product not found" + }); + } + + const [result] = await connection.query( + ` + INSERT INTO reviews (product_id, user_id, rating, comment) + VALUES (?, ?, ?, ?) + `, + [productId, userId, rating, comment] + ); + + const stats = await refreshProductReviewStats(productId, connection); + + await connection.commit(); + + return res.status(201).json({ + success: true, + message: "Review submitted successfully", + reviewId: result.insertId, + ...stats + }); + } catch (error) { + await connection.rollback(); + console.error("CREATE PRODUCT REVIEW ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to submit review" + }); + } finally { + connection.release(); + } +}; + +const deleteProductReview = async (req, res) => { + const productId = safeInteger(req.params.id); + const reviewId = safeInteger(req.params.reviewId); + const connection = await db.getConnection(); + + if (!productId || !reviewId) { + connection.release(); + + return res.status(400).json({ + success: false, + message: "Invalid review request" + }); + } + + try { + await connection.beginTransaction(); + + const [result] = await connection.query( + "DELETE FROM reviews WHERE id = ? AND product_id = ?", + [reviewId, productId] + ); + + if (result.affectedRows === 0) { + await connection.rollback(); + + return res.status(404).json({ + success: false, + message: "Review not found" + }); + } + + const stats = await refreshProductReviewStats(productId, connection); + + await connection.commit(); + + return res.status(200).json({ + success: true, + message: "Review deleted successfully", + ...stats + }); + } catch (error) { + await connection.rollback(); + console.error("DELETE PRODUCT REVIEW ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to delete review" + }); + } finally { + connection.release(); + } +}; + +module.exports = { + getProductReviews, + createProductReview, + deleteProductReview +}; diff --git a/backend/models/Review.js b/backend/models/Review.js new file mode 100644 index 0000000..7f5d224 --- /dev/null +++ b/backend/models/Review.js @@ -0,0 +1,13 @@ +class Review { + constructor(review) { + this.id = review.id; + this.productId = review.product_id; + this.userId = review.user_id; + this.userName = review.user_name || review.name || ""; + this.rating = review.rating || 0; + this.comment = review.comment || ""; + this.createdAt = review.created_at || new Date(); + } +} + +module.exports = Review; diff --git a/backend/routes/chatRoutes.js b/backend/routes/chatRoutes.js index 3aaca0d..09ccbd8 100644 --- a/backend/routes/chatRoutes.js +++ b/backend/routes/chatRoutes.js @@ -1,6 +1,7 @@ const express = require("express"); const router = express.Router(); -const { authMiddleware, authorizeRoles } = require("../middleware/authMiddleware"); +const authMiddleware = require("../middleware/authMiddleware"); +const { authorizeRoles } = require("../middleware/rbacMiddleware"); const { getConversations, getConversationDetails, updateStatus, assignAdmin } = require("../controllers/chat.controller"); // Admin only routes diff --git a/backend/routes/productRoutes.js b/backend/routes/productRoutes.js index 77f9475..dc2f22e 100644 --- a/backend/routes/productRoutes.js +++ b/backend/routes/productRoutes.js @@ -5,8 +5,14 @@ const { getSingleProduct, createProduct, updateProduct, - DeleteeProduct + DeleteeProduct, + getProductSuggestions } = require("../controllers/productController"); +const { + getProductReviews, + createProductReview, + deleteProductReview +} = require("../controllers/reviewController"); const authMiddleware = require("../middleware/authMiddleware"); const { authorizeRoles } = require("../middleware/rbacMiddleware"); const { sanitizeString, safeNumber } = require("../utils/helpers"); @@ -31,10 +37,16 @@ router.get("/status/check", (req, res) => { }); router.get("/", getProducts); -router.get("/:id", getSingleProduct); - -// NEW: search suggestions endpoint (autocomplete) router.get("/search-suggestions", getProductSuggestions); +router.get("/:id/reviews", getProductReviews); +router.post("/:id/review", authMiddleware, createProductReview); +router.delete( + "/:id/reviews/:reviewId", + authMiddleware, + authorizeRoles("admin"), + deleteProductReview +); +router.get("/:id", getSingleProduct); router.post("/", authMiddleware, authorizeRoles("admin"), (req, res, next) => { const { name, category, price, stock } = req.body; @@ -70,7 +82,7 @@ router.put("/:id", authMiddleware, authorizeRoles("admin"), (req, res, next) => next(); }, updateProduct); -router.Deletee("/:id", authMiddleware, authorizeRoles("admin"), DeleteeProduct); +router.delete("/:id", authMiddleware, authorizeRoles("admin"), DeleteeProduct); // Fallback router.use((req, res) => { diff --git a/backend/schema.sql b/backend/schema.sql index 4499745..1c74f59 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS products ( stock INT DEFAULT 0, - image VARCHAR(500), + image TEXT, category VARCHAR(100), @@ -121,7 +121,7 @@ CREATE TABLE IF NOT EXISTS orders ( FOREIGN KEY (user_id) REFERENCES users(id) - ON DeleteE SET NULL, + ON DELETE SET NULL, CHECK (total >= 0) ); @@ -151,11 +151,11 @@ CREATE TABLE IF NOT EXISTS order_items ( FOREIGN KEY (order_id) REFERENCES orders(id) - ON DeleteE CASCADE, + ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) - ON DeleteE SET NULL, + ON DELETE SET NULL, CHECK (price >= 0), @@ -196,11 +196,11 @@ CREATE TABLE IF NOT EXISTS wishlist_items ( FOREIGN KEY (user_id) REFERENCES users(id) - ON DeleteE CASCADE, + ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) - ON DeleteE CASCADE, + ON DELETE CASCADE, UNIQUE KEY user_product_unique (user_id, product_id) ); @@ -212,6 +212,42 @@ ON wishlist_items(user_id); CREATE INDEX idx_wishlist_items_product ON wishlist_items(product_id); +-- reviews table +CREATE TABLE IF NOT EXISTS reviews ( + id INT AUTO_INCREMENT PRIMARY KEY, + + product_id INT + NOT NULL, + + user_id INT + NOT NULL, + + rating TINYINT + NOT NULL, + + comment TEXT + NOT NULL, + + created_at TIMESTAMP + DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE CASCADE, + + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CHECK (rating >= 1 AND rating <= 5) +); + +CREATE INDEX idx_reviews_product +ON reviews(product_id); + +CREATE INDEX idx_reviews_user +ON reviews(user_id); + -- user interactions table CREATE TABLE IF NOT EXISTS user_interactions ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/backend/seedProducts.js b/backend/seedProducts.js new file mode 100644 index 0000000..eb5d288 --- /dev/null +++ b/backend/seedProducts.js @@ -0,0 +1,44 @@ +const mysql = require("mysql2/promise"); +require("dotenv").config(); + +const products = [ + { name: 'Classic Cotton T-Shirt', description: 'Summer collection soft cotton tee.', price: 19.99, image: '/assets/images/f1.jpg', category: 'T-Shirts', stock: 50, featured: 1}, + { name: 'Graphic Summer Tee', description: 'Vibrant graphic tee for summer.', price: 24.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRyY_WkHP0-WBtjcePjG1sLSoMouKgAaav1hg&s', category: 'T-Shirts', stock: 30, featured: 0}, + { name: 'Striped Casual Tee', description: 'Comfortable striped tee.', price: 21.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtKfu-ZwefkG7NDs5d3PhgFBSgTHhEN01ENQ&s', category: 'T-Shirts', stock: 22, featured: 0}, + { name: 'V-Neck Tee', description: 'Soft v-neck t-shirt.', price: 17.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTsSS_5k6nLRteqeDYfhRBiA65nBAAXQA2Nwg&s', category: 'T-Shirts', stock: 18, featured: 0}, + { name: 'Pocket Tee', description: 'Casual pocket tee.', price: 18.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRuw0Ab_4tzp6NAd8VHYZzV9OYF59aklaeEeA&s', category: 'T-Shirts', stock: 12, featured: 0}, + + { name: 'Cozy Hoodie', description: 'Lightweight hoodie for cool evenings.', price: 39.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSPkTgVrxAbFAb4VMnRmqKfLq1mlPwKCf3PQg&s', category: 'Hoodies', stock: 40, featured: 1}, + { name: 'Zip-Up Hoodie', description: 'Casual zip hoodie.', price: 44.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQEk5MDT4E_9vjAF7bci9TCMuPYw_yUrdJ1Gw&s', category: 'Hoodies', stock: 40, featured: 0}, + { name: 'Pullover Hoodie', description: 'Cozy pullover style.', price: 42.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTua6NALhSutNTSeAx3JEGPipEhDhEoUAoISw&s', category: 'Hoodies', stock: 28, featured: 0}, + { name: 'Fleece Hoodie', description: 'Warm fleece hoodie.', price: 49.99, image: '/assets/images/f2.jpg', category: 'Hoodies', stock: 14, featured: 0}, + { name: 'Sport Hoodie', description: 'Performance hoodie for workouts.', price: 46.99, image: '/assets/images/f3.jpg', category: 'Hoodies', stock: 32, featured: 0}, + + { name: 'Windbreaker Jacket', description: 'Water-resistant windbreaker.', price: 59.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRUk5pMwkIaq5m32eSfcLfuTTYXPd_gZxmDrg&s', category: 'Jackets', stock: 20, featured: 0}, + { name: 'Denim Jacket', description: 'Classic denim jacket.', price: 69.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS742gOUs3PYj6pGiEL7vxtCYtcdntbkSzg1Q&s', category: 'Jackets', stock: 15, featured: 1}, + { name: 'Leather Jacket', description: 'Stylish faux-leather jacket.', price: 119.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS3Zm904oGz9FyfDetC1LY41uK35Udmgl01bQ&s', category: 'Jackets', stock: 8, featured: 0}, + { name: 'Bomber Jacket', description: 'Classic bomber jacket.', price: 89.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTT_Z8bOxgvwY4ahNxGg4TkryUn2gowHQL51w&s', category: 'Jackets', stock: 11, featured: 0}, + { name: 'Denim Trucker', description: 'Lightweight trucker jacket.', price: 74.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQOazqR3Pt7KgK3iTwtReHvCLpQaIKhphOTjA&s', category: 'Jackets', stock: 6, featured: 0} +]; + +async function seed() { + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: 3306 + }); + + console.log("Connected to DB, inserting products..."); + for (const p of products) { + await connection.execute( + `INSERT INTO products (name, description, price, image, category, stock, featured) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [p.name, p.description, p.price, p.image, p.category, p.stock, p.featured] + ); + } + console.log("Successfully seeded database with placeholder products!"); + await connection.end(); +} + +seed().catch(console.error); diff --git a/frontend/product.html b/frontend/product.html index 162947a..f15ea26 100644 --- a/frontend/product.html +++ b/frontend/product.html @@ -653,49 +653,86 @@

-
+

+ Loading reviews... +

- + - - + @@ -794,4 +831,4 @@

- \ No newline at end of file + diff --git a/frontend/scripts/auth.js b/frontend/scripts/auth.js index b3747c3..8a7c489 100644 --- a/frontend/scripts/auth.js +++ b/frontend/scripts/auth.js @@ -176,8 +176,14 @@ function saveAuthSession( return; } - // Tokens are securely stored in HttpOnly cookies by the backend. + if (response.accessToken) { + localStorage.setItem(CONFIG.STORAGE_KEYS.TOKEN, response.accessToken); + } + if (response.refreshToken) { + localStorage.setItem(CONFIG.STORAGE_KEYS.REFRESH_TOKEN, response.refreshToken); + } + AppUtils.setJSON( CONFIG.STORAGE_KEYS.USER, response.user || {} @@ -879,59 +885,6 @@ document.querySelectorAll( ); }); -// ======================================== -// Password Strength Meter (Issue #166) -// ======================================== -function evaluatePasswordStrength(password) { - let score = 0; - const tips = []; - - if (password.length >= 8) score++; - else tips.push('At least 8 characters'); - - if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++; - else tips.push('Include both uppercase and lowercase letters'); - - if (/\d/.test(password)) score++; - else tips.push('Include at least one number'); - - if (/[^a-zA-Z0-9]/.test(password)) score++; - else tips.push('Include at least one special character'); - - let level = 'Weak'; - let color = 'strength-weak'; - let percent = 25; - if (score === 4) { level = 'Strong'; color = 'strength-strong'; percent = 100; } - else if (score === 3) { level = 'Medium'; color = 'strength-medium'; percent = 70; } - else if (score === 2) { level = 'Weak'; color = 'strength-weak'; percent = 45; } - else { percent = 20; } - - return { level, color, percent, tips }; -} - -function updatePasswordStrength() { - const passwordInput = document.getElementById('signup-password'); - const fill = document.getElementById('password-strength-fill'); - const text = document.getElementById('password-strength-text'); - const tips = document.getElementById('password-strength-tips'); - const signupBtn = document.getElementById('signup-btn'); - - if (!passwordInput || !fill || !text || !tips) return; - - const password = passwordInput.value; - const result = evaluatePasswordStrength(password); - - fill.style.width = result.percent + '%'; - fill.className = result.color; - text.textContent = result.level; - text.style.color = result.level === 'Strong' ? '#28a745' : result.level === 'Medium' ? '#ffa500' : '#ff4d4d'; - - if (password.length === 0) { - tips.textContent = ''; - if (signupBtn) signupBtn.disabled = true; - return; - } -); // ======================================== // Password Strength Meter (Issue #166) diff --git a/frontend/scripts/product-cards-home.js b/frontend/scripts/product-cards-home.js index 38ebfbf..fb0cb66 100644 --- a/frontend/scripts/product-cards-home.js +++ b/frontend/scripts/product-cards-home.js @@ -31,7 +31,7 @@ function createProductCard(product) { ).join(""); return ` -
+
${ product.featured ? ` diff --git a/frontend/scripts/product-render.js b/frontend/scripts/product-render.js index f9138b1..36fff39 100644 --- a/frontend/scripts/product-render.js +++ b/frontend/scripts/product-render.js @@ -89,6 +89,33 @@ function renderStars( return stars; } +function getProductReviewCount( + product +) { + + return Number( + product?.num_reviews + ?? product?.numReviews + ?? product?.reviewCount + ?? 0 + ); +} + +function formatRatingText( + rating, + count +) { + + if ( + !count + ) { + + return "No reviews yet"; + } + + return `${safeNumber(rating, 0).toFixed(1)} (${count} review${count === 1 ? "" : "s"})`; +} + // create product card html function createProductCardHTML( product @@ -141,6 +168,18 @@ function createProductCardHTML( product.rating || 4 ) } + + ${ + escapeHTML( + formatRatingText( + product.rating || 0, + getProductReviewCount( + product + ) + ) + ) + } +

@@ -311,7 +350,12 @@ function renderProductRating( const rating = safeNumber( product.rating, - 4.5 + 0 + ); + + const reviewCount = + getProductReviewCount( + product ); ratingContainer.innerHTML = @@ -323,10 +367,14 @@ function renderProductRating( } - ( - ${rating} - Ratings - ) + ${ + escapeHTML( + formatRatingText( + rating, + reviewCount + ) + ) + } `; } @@ -576,4 +624,4 @@ window.renderProductRating = window.updateRecentlyViewed = updateRecentlyViewed; - window.allProducts = window.allProducts || []; \ No newline at end of file + window.allProducts = window.allProducts || []; diff --git a/frontend/scripts/product-reviews.js b/frontend/scripts/product-reviews.js index 959e6e8..6a9f752 100644 --- a/frontend/scripts/product-reviews.js +++ b/frontend/scripts/product-reviews.js @@ -1,277 +1,311 @@ -// reviews state -let productReviews = - []; - -// review elements -const reviewForm = - document.getElementById( - "review-form" +(() => { + let productReviews = []; + let activeProductId = null; + let selectedRating = 0; + + const reviewForm = document.getElementById("review-form"); + const reviewContainer = document.getElementById("reviews-container"); + const reviewSummary = document.getElementById("reviews-summary"); + const reviewRatingInput = document.getElementById("review-rating"); + const reviewMessageInput = document.getElementById("review-message"); + const starButtons = Array.from( + document.querySelectorAll(".review-star-input button") ); -const reviewContainer = - document.getElementById( - "reviews-container" - ); + function getCurrentUser() { + return AppUtils.getUser ? AppUtils.getUser() : null; + } -const reviewRatingInput = - document.getElementById( - "review-rating" - ); + function isCurrentUserAdmin() { + return getCurrentUser()?.role === "admin"; + } -// load reviews -function loadProductReviews( - productId -) { - const stored = - AppUtils.getJSON( - `reviews_${productId}`, - [] - ); - - productReviews = - Array.isArray( - stored - ) - ? stored - : []; - - renderReviews(); -} - -// save reviews -function saveProductReviews( - productId -) { - AppUtils.setJSON( - `reviews_${productId}`, - productReviews - ); -} - -// render single review -function createReviewCard( - review -) { - return ` -
-
-

- ${ - AppUtils.escapeHTML(review.name) - } -

+ function formatReviewDate(value) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return ""; + } + + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric" + }); + } + + function updateRatingDisplay(averageRating = 0, reviewCount = 0) { + const rating = Number(averageRating || 0); + const count = Number(reviewCount || 0); + const currentProduct = window.currentProductData; + + if (currentProduct) { + currentProduct.rating = rating; + currentProduct.num_reviews = count; + } + + if (typeof window.renderProductRating === "function" && currentProduct) { + window.renderProductRating(currentProduct); + } + + if (reviewSummary) { + reviewSummary.textContent = count + ? `${rating.toFixed(1)} average rating from ${count} review${count === 1 ? "" : "s"}` + : "No reviews yet. Be the first to review this product."; + } + } + + function renderStars(rating = 0) { + const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0))); + + return Array.from({ length: 5 }, (_, index) => { + const className = index < safeRating ? "fas fa-star" : "far fa-star"; + return ``; + }).join(""); + } + + function setSelectedRating(value) { + selectedRating = Math.max(0, Math.min(5, Number(value) || 0)); + + if (reviewRatingInput) { + reviewRatingInput.value = selectedRating ? String(selectedRating) : ""; + } + + starButtons.forEach((button) => { + const rating = Number(button.dataset.rating); + const isActive = rating <= selectedRating; + const icon = button.querySelector("i"); + + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(rating === selectedRating)); + + if (icon) { + icon.className = isActive ? "fas fa-star" : "far fa-star"; + } + }); + } + + function createReviewCard(review) { + const canDelete = isCurrentUserAdmin(); + const reviewId = Number(review.id); + + return ` +
+
+
+

${AppUtils.escapeHTML(review.userName || "Customer")}

+
+ ${renderStars(review.rating)} +
+
-
${ - renderStars( - review.rating - ) + canDelete && reviewId + ? `` + : "" } -
-
+ -

- ${ - AppUtils.escapeHTML(review.message) - } -

+

${AppUtils.escapeHTML(review.comment)}

- - ${ - review.date - } - -
- `; -} - -// render reviews -function renderReviews() { - if ( - !reviewContainer - ) { - return; + + + `; + } + + function renderReviews() { + if (!reviewContainer) { + return; + } + + if (!productReviews.length) { + reviewContainer.innerHTML = ` +

+ No reviews yet +

+ `; + + return; + } + + reviewContainer.innerHTML = productReviews.map(createReviewCard).join(""); } - if ( - !productReviews.length - ) { + async function loadProductReviews(productId) { + if (!productId) { + const urlParams = new URLSearchParams(window.location.search); + productId = urlParams.get("id"); + } + activeProductId = Number(productId); + + if (!activeProductId || !reviewContainer) { + return; + } + reviewContainer.innerHTML = `

- No reviews yet + Loading reviews...

`; - return; + try { + const response = await AppUtils.apiRequest(`/products/${activeProductId}/reviews`); + + if (!response.success) { + throw new Error(response.message || "Failed to load reviews"); + } + + productReviews = AppUtils.safeArray(response.reviews); + updateRatingDisplay(response.averageRating, response.reviewCount); + renderReviews(); + } catch (error) { + console.error("LOAD REVIEWS ERROR:", error); + productReviews = []; + reviewContainer.innerHTML = ` +

+ Reviews could not be loaded right now. +

+ `; + } } - reviewContainer.innerHTML = - productReviews - .map( - createReviewCard - ) - .join(""); -} - -// average rating -function getAverageRating() { - if ( - !productReviews.length - ) { - return 0; - } + async function submitReview(event) { + event.preventDefault(); - const total = - productReviews.reduce( - ( - sum, - review - ) => { - return ( - sum + - Number( - review.rating || 0 - ) - ); - }, - 0 - ); - - return ( - total / - productReviews.length - ).toFixed(1); -} - -// submit review -function submitReview( - event -) { - event.preventDefault(); - if ( - !window.currentProductData - ) { - notify( - "Product unavailable", - "error" - ); - - return; - } + if (!activeProductId) { + const urlParams = new URLSearchParams(window.location.search); + activeProductId = Number(urlParams.get("id")); + } + + if (!activeProductId || Number.isNaN(activeProductId)) { + AppUtils.notify("Product unavailable", "error"); + return; + } + + if (!AppUtils.requireLogin("Please sign in to review this product")) { + return; + } + + const rating = Number(reviewRatingInput?.value || 0); + const comment = reviewMessageInput?.value.trim() || ""; + + if (rating < 1 || rating > 5) { + AppUtils.notify("Choose a rating from 1 to 5 stars", "error"); + return; + } + + if (comment.length < 3 || comment.length > 1000) { + AppUtils.notify("Review comment must be between 3 and 1000 characters", "error"); + return; + } + + const submitButton = reviewForm.querySelector('button[type="submit"]'); + submitButton.disabled = true; + + try { + const response = await AppUtils.apiRequest( + `/products/${activeProductId}/review`, + { + method: "POST", + body: JSON.stringify({ + rating, + comment + }) + } + ); - const nameInput = - document.getElementById( - "review-name" - ); - - const messageInput = - document.getElementById( - "review-message" - ); - - const name = - nameInput?.value.trim(); - - const message = - messageInput?.value.trim(); - - const rating = - parseInt( - reviewRatingInput?.value - ) || 5; - - if ( - !name || - !message - ) { - notify( - "Please fill all review fields", - "error" - ); - return; + if (!response.success) { + throw new Error(response.message || "Failed to submit review"); + } + + AppUtils.notify("Review submitted successfully", "success"); + reviewForm.reset(); + setSelectedRating(0); + await loadProductReviews(activeProductId); + } catch (error) { + console.error("SUBMIT REVIEW ERROR:", error); + AppUtils.notify(error.message || "Failed to submit review", "error"); + } finally { + submitButton.disabled = false; + } } - const review = { - name, - message, - rating, - date: - new Date() - .toLocaleDateString() - }; - - productReviews.unshift( - review - ); + async function deleteReview(reviewId) { + if (!activeProductId || !reviewId) { + return; + } - saveProductReviews( - window.currentProductData.id - ); + const confirmed = window.confirm("Delete this review?"); - renderReviews(); - notify( - "Review submitted successfully", - "success" - ); - reviewForm.reset(); -} - -// star interaction -document - .querySelectorAll( - ".review-star-input i" - ) - .forEach( - ( - star, - index - ) => { - star.addEventListener( - "click", - () => { - if ( - reviewRatingInput - ) { - reviewRatingInput.value = - index + 1; - } + if (!confirmed) { + return; + } - document - .querySelectorAll( - ".review-star-input i" - ) - .forEach( - ( - current, - currentIndex - ) => { - current.style.color = - currentIndex <= index - ? "gold" - : "#ccc"; - } - ); + try { + const response = await AppUtils.apiRequest( + `/products/${activeProductId}/reviews/${reviewId}`, + { + method: "DELETE" } ); + + if (!response.success) { + throw new Error(response.message || "Failed to delete review"); + } + + AppUtils.notify("Review deleted", "success"); + updateRatingDisplay(response.averageRating, response.reviewCount); + await loadProductReviews(activeProductId); + } catch (error) { + console.error("DELETE REVIEW ERROR:", error); + AppUtils.notify(error.message || "Failed to delete review", "error"); } - ); + } -// form listener -if ( - reviewForm -) { - reviewForm.addEventListener( - "submit", - submitReview - ); -} + starButtons.forEach((button) => { + button.addEventListener("click", () => { + setSelectedRating(button.dataset.rating); + }); + + button.addEventListener("mouseenter", () => { + const hoverRating = Number(button.dataset.rating); + + starButtons.forEach((current) => { + const icon = current.querySelector("i"); + const isActive = Number(current.dataset.rating) <= hoverRating; + + if (icon) { + icon.className = isActive ? "fas fa-star" : "far fa-star"; + } + }); + }); + }); + + const starInput = document.querySelector(".review-star-input"); + + starInput?.addEventListener("mouseleave", () => { + setSelectedRating(selectedRating); + }); -// expose globally -window.loadProductReviews = - loadProductReviews; + reviewForm?.addEventListener("submit", submitReview); -window.renderReviews = - renderReviews; + reviewContainer?.addEventListener("click", (event) => { + const deleteButton = event.target.closest(".review-delete-btn"); + + if (deleteButton) { + deleteReview(Number(deleteButton.dataset.reviewId)); + } + }); -window.getAverageRating = - getAverageRating; \ No newline at end of file + window.loadProductReviews = loadProductReviews; + window.renderReviews = renderReviews; +})(); diff --git a/frontend/scripts/product.js b/frontend/scripts/product.js index 41bc7b9..5a6b108 100644 --- a/frontend/scripts/product.js +++ b/frontend/scripts/product.js @@ -276,6 +276,9 @@ async function fetchProduct() { currentProductData = response.product; + window.currentProductData = + currentProductData; + cacheProduct( response.product ); @@ -286,6 +289,9 @@ async function fetchProduct() { getCachedProduct() || getFallbackProduct(); + + window.currentProductData = + currentProductData; } } catch (error) { @@ -300,6 +306,9 @@ async function fetchProduct() { || getFallbackProduct(); + window.currentProductData = + currentProductData; + } finally { initializeProductPage(); @@ -355,6 +364,16 @@ function initializeProductPage() { product ); + if ( + typeof window.renderProductRating === + "function" + ) { + + window.renderProductRating( + product + ); + } + if ( typeof setupVariants === "function" @@ -882,4 +901,4 @@ document.addEventListener( } ); -})(); \ No newline at end of file +})(); diff --git a/frontend/scripts/shop.js b/frontend/scripts/shop.js index 726ae78..f435d05 100644 --- a/frontend/scripts/shop.js +++ b/frontend/scripts/shop.js @@ -231,6 +231,24 @@ function renderStars( ).join(""); } +function getReviewCount(product) { + return Number( + product?.num_reviews ?? + product?.numReviews ?? + product?.reviewCount ?? + 0 + ); +} + +function getRatingLabel(product) { + const count = getReviewCount(product); + const rating = Number(product.rating || 0); + + return count + ? `${rating.toFixed(1)} (${count} review${count === 1 ? "" : "s"})` + : "No reviews yet"; +} + // PRODUCT CARD function createProductCard( product @@ -264,6 +282,9 @@ function createProductCard( ${renderStars( product.rating )} + + ${AppUtils.escapeHTML(getRatingLabel(product))} +

${AppUtils.formatPrice( diff --git a/frontend/scripts/utils.js b/frontend/scripts/utils.js index f08c8db..108f4b8 100644 --- a/frontend/scripts/utils.js +++ b/frontend/scripts/utils.js @@ -249,6 +249,13 @@ const refreshAccessToken = try { + const refreshToken = localStorage.getItem(CONFIG.STORAGE_KEYS.REFRESH_TOKEN); + + if (!refreshToken) { + clearAuthData(); + return null; + } + const response = await fetch( `${CONFIG.API_BASE}/auth/refresh-token`, @@ -262,8 +269,8 @@ const refreshAccessToken = "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({}) + credentials: "omit", + body: JSON.stringify({ refreshToken }) } ); @@ -282,7 +289,13 @@ const refreshAccessToken = return null; } - // save user + // save tokens and user + if (data.accessToken) { + localStorage.setItem(CONFIG.STORAGE_KEYS.TOKEN, data.accessToken); + } + if (data.refreshToken) { + localStorage.setItem(CONFIG.STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken); + } if (data.user) { setJSON(CONFIG.STORAGE_KEYS.USER, data.user); } @@ -336,11 +349,14 @@ const apiRequest = try { + const token = localStorage.getItem(CONFIG.STORAGE_KEYS.TOKEN); const headers = { "Content-Type": "application/json", + ...(token ? { "Authorization": `Bearer ${token}` } : {}), + ...(options.headers || {}) }; @@ -844,9 +860,6 @@ const saveWishlist = ( ); }; -const getToken = () => { - return getUser() ? "session_active" : null; -}; // app utils assignment window.AppUtils = { @@ -878,7 +891,6 @@ window.AppUtils = { saveCart, getWishlist, saveWishlist, - getToken, requireLogin, loadUserCollections }; diff --git a/frontend/styles/product-card.css b/frontend/styles/product-card.css index 85a7d40..3012895 100644 --- a/frontend/styles/product-card.css +++ b/frontend/styles/product-card.css @@ -81,6 +81,22 @@ product section color: rgb(243, 181, 25); } +.pro .star{ + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 3px; + padding-top: 6px; +} + +.pro .rating-count{ + color: #606063; + display: inline-block; + font-size: 12px; + line-height: 1.4; + margin-left: 4px; +} + .pro .des h4 { padding-top: 7px; font-size: 15px; @@ -308,6 +324,10 @@ body.dark-theme .pro .des i { color: rgb(243, 181, 25); } +body.dark-theme .pro .rating-count { + color: #aaaaaa; +} + /* Syling for men's and women's section */ .quantity-controller { display: flex !important; diff --git a/frontend/styles/product.css b/frontend/styles/product.css index de76263..367082b 100644 --- a/frontend/styles/product.css +++ b/frontend/styles/product.css @@ -163,6 +163,11 @@ margin-bottom: 12px; } +.reviews-summary{ + color: #555; + margin-bottom: 18px; +} + @media (max-width: 1024px){ #product-details{ flex-direction: column; @@ -186,6 +191,20 @@ box-shadow: 0 5px 25px rgba(0,0,0,0.08); } +#review-form fieldset{ + border: 0; + margin: 0 0 18px; + padding: 0; +} + +#review-form legend, +.review-label{ + display: block; + color: #222; + font-weight: 600; + margin-bottom: 10px; +} + #review-form input, #review-form select, #review-form textarea{ @@ -211,11 +230,64 @@ cursor: pointer; } +.review-star-input{ + display: flex; + gap: 6px; +} + +.review-star-input button{ + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: #c8c8c8; + cursor: pointer; + display: inline-flex; + font-size: 24px; + height: 42px; + justify-content: center; + padding: 0; + width: 42px; +} + +.review-star-input button:hover, +.review-star-input button:focus-visible, +.review-star-input button.is-active{ + color: #f5b301; +} + +.review-star-input button:focus-visible, +.review-delete-btn:focus-visible{ + border-color: #088178; + outline: 2px solid rgba(8,129,120,0.25); + outline-offset: 2px; +} + .review-stars{ color: #f5b301; margin-bottom: 12px; } +.review-header{ + align-items: flex-start; + display: flex; + gap: 16px; + justify-content: space-between; +} + +.review-message{ + overflow-wrap: anywhere; +} + +.review-delete-btn{ + background: #b42318; + border: 0; + border-radius: 6px; + color: #fff; + cursor: pointer; + padding: 8px 12px; +} + .review-date{ font-size: 13px; color: #777; @@ -501,6 +573,7 @@ body.dark-theme .review-box p { color: #d1d1d1; } +body.dark-theme .reviews-summary, body.dark-theme .review-date { color: #aaaaaa; } @@ -524,6 +597,21 @@ body.dark-theme #review-form textarea::placeholder { color: #888; } +body.dark-theme #review-form legend, +body.dark-theme .review-label { + color: #ffffff; +} + +body.dark-theme .review-star-input button { + color: #666; +} + +body.dark-theme .review-star-input button:hover, +body.dark-theme .review-star-input button:focus-visible, +body.dark-theme .review-star-input button.is-active { + color: #f5b301; +} + /* Recommended products */ body.dark-theme #recommended-products .pro { background: #1e1e1e; @@ -611,4 +699,4 @@ body.dark-theme .review-container p { color: #666; margin-top: 8px; font-size: 14px; -} \ No newline at end of file +} From 0a18cc4b78549ca8127e12bb5207e60c61a0c537 Mon Sep 17 00:00:00 2001 From: ValayaDase Date: Wed, 24 Jun 2026 00:05:39 +0530 Subject: [PATCH 2/3] review feature updates --- backend/controllers/productController.js | 4 --- backend/controllers/reviewController.js | 14 +++++++++++ backend/routes/productRoutes.js | 31 +----------------------- frontend/scripts/product.js | 16 ++++++------ 4 files changed, 22 insertions(+), 43 deletions(-) diff --git a/backend/controllers/productController.js b/backend/controllers/productController.js index 4381c97..f4619e6 100644 --- a/backend/controllers/productController.js +++ b/backend/controllers/productController.js @@ -464,8 +464,4 @@ module.exports = { updateProduct, DeleteeProduct, getProductSuggestions -<<<<<<< HEAD }; -======= -}; ->>>>>>> 76d9fb2a590eb1302b4a3cd0c621f7d1a65492c4 diff --git a/backend/controllers/reviewController.js b/backend/controllers/reviewController.js index c6691cd..4dfa43a 100644 --- a/backend/controllers/reviewController.js +++ b/backend/controllers/reviewController.js @@ -163,6 +163,20 @@ const createProductReview = async (req, res) => { }); } + const [existing] = await connection.query( + "SELECT id FROM reviews WHERE product_id = ? AND user_id = ? LIMIT 1", + [productId, userId] + ); + + if (safeArray(existing).length > 0) { + await connection.rollback(); + + return res.status(400).json({ + success: false, + message: "You have already reviewed this product" + }); + } + const [result] = await connection.query( ` INSERT INTO reviews (product_id, user_id, rating, comment) diff --git a/backend/routes/productRoutes.js b/backend/routes/productRoutes.js index a9b4bd7..edcbfe1 100644 --- a/backend/routes/productRoutes.js +++ b/backend/routes/productRoutes.js @@ -9,15 +9,13 @@ const { DeleteeProduct, getProductSuggestions } = require("../controllers/productController"); -<<<<<<< HEAD + const { getProductReviews, createProductReview, deleteProductReview } = require("../controllers/reviewController"); -======= ->>>>>>> 76d9fb2a590eb1302b4a3cd0c621f7d1a65492c4 const authMiddleware = require("../middleware/authMiddleware"); const { authorizeRoles } = require("../middleware/rbacMiddleware"); const { validateCreateProduct, validateUpdateProduct } = require("../middleware/validators/productValidator"); @@ -43,8 +41,6 @@ router.get("/status/check", (req, res) => { router.get("/search-suggestions", getProductSuggestions); router.get("/", getProducts); -<<<<<<< HEAD -router.get("/search-suggestions", getProductSuggestions); router.get("/:id/reviews", getProductReviews); router.post("/:id/review", authMiddleware, createProductReview); router.delete( @@ -54,34 +50,9 @@ router.delete( deleteProductReview ); router.get("/:id", getSingleProduct); -======= -router.get("/:id", getSingleProduct); router.post("/", authMiddleware, authorizeRoles("admin"), validateCreateProduct, createProduct); ->>>>>>> 76d9fb2a590eb1302b4a3cd0c621f7d1a65492c4 - router.put("/:id", authMiddleware, authorizeRoles("admin"), validateUpdateProduct, updateProduct); - -<<<<<<< HEAD -router.put("/:id", authMiddleware, authorizeRoles("admin"), (req, res, next) => { - const { name, category, price, stock } = req.body; - if (name !== undefined && !sanitizeString(name)) { - return res.status(400).json({ success: false, message: "Product name cannot be empty" }); - } - if (category !== undefined && !sanitizeString(category)) { - return res.status(400).json({ success: false, message: "Category cannot be empty" }); - } - if (price !== undefined && safeNumber(price) < 0) { - return res.status(400).json({ success: false, message: "Price cannot be negative" }); - } - if (stock !== undefined && safeNumber(stock) < 0) { - return res.status(400).json({ success: false, message: "Stock cannot be negative" }); - } - next(); -}, updateProduct); - -======= ->>>>>>> 76d9fb2a590eb1302b4a3cd0c621f7d1a65492c4 router.delete("/:id", authMiddleware, authorizeRoles("admin"), DeleteeProduct); // Fallback diff --git a/frontend/scripts/product.js b/frontend/scripts/product.js index 5a6b108..b9e2113 100644 --- a/frontend/scripts/product.js +++ b/frontend/scripts/product.js @@ -396,15 +396,6 @@ function initializeProductPage() { window.syncProductQtyControls(); } - if ( - typeof loadProductReviews === - "function" - ) { - - loadProductReviews( - product.id - ); - } if ( typeof loadRelatedProducts === @@ -891,6 +882,13 @@ document.addEventListener( fetchProduct(); + if ( + typeof loadProductReviews === + "function" + ) { + loadProductReviews(productId); + } + if ( typeof updateCartCount === "function" From 20fc204e395ff9cf51598534d86a9242e7fa74d6 Mon Sep 17 00:00:00 2001 From: ValayaDase Date: Sat, 27 Jun 2026 20:36:56 +0530 Subject: [PATCH 3/3] merge solved --- frontend/scripts/product-reviews.js | 452 ++++++++++++++-------------- 1 file changed, 231 insertions(+), 221 deletions(-) diff --git a/frontend/scripts/product-reviews.js b/frontend/scripts/product-reviews.js index 6a9f752..0e73b2b 100644 --- a/frontend/scripts/product-reviews.js +++ b/frontend/scripts/product-reviews.js @@ -1,95 +1,98 @@ (() => { - let productReviews = []; - let activeProductId = null; - let selectedRating = 0; - - const reviewForm = document.getElementById("review-form"); - const reviewContainer = document.getElementById("reviews-container"); - const reviewSummary = document.getElementById("reviews-summary"); - const reviewRatingInput = document.getElementById("review-rating"); - const reviewMessageInput = document.getElementById("review-message"); - const starButtons = Array.from( - document.querySelectorAll(".review-star-input button") - ); - - function getCurrentUser() { - return AppUtils.getUser ? AppUtils.getUser() : null; - } - - function isCurrentUserAdmin() { - return getCurrentUser()?.role === "admin"; + let productReviews = []; + let activeProductId = null; + let selectedRating = 0; + + const reviewForm = document.getElementById("review-form"); + const reviewContainer = document.getElementById("reviews-container"); + const reviewSummary = document.getElementById("reviews-summary"); + const reviewRatingInput = document.getElementById("review-rating"); + const reviewMessageInput = document.getElementById("review-message"); + const starButtons = Array.from( + document.querySelectorAll(".review-star-input button"), + ); + + function getCurrentUser() { + return AppUtils.getUser ? AppUtils.getUser() : null; + } + + function isCurrentUserAdmin() { + return getCurrentUser()?.role === "admin"; + } + + function formatReviewDate(value) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return ""; } - function formatReviewDate(value) { - const date = new Date(value); + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } - if (Number.isNaN(date.getTime())) { - return ""; - } + function updateRatingDisplay(averageRating = 0, reviewCount = 0) { + const rating = Number(averageRating || 0); + const count = Number(reviewCount || 0); + const currentProduct = window.currentProductData; - return date.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric" - }); + if (currentProduct) { + currentProduct.rating = rating; + currentProduct.num_reviews = count; } - function updateRatingDisplay(averageRating = 0, reviewCount = 0) { - const rating = Number(averageRating || 0); - const count = Number(reviewCount || 0); - const currentProduct = window.currentProductData; - - if (currentProduct) { - currentProduct.rating = rating; - currentProduct.num_reviews = count; - } - - if (typeof window.renderProductRating === "function" && currentProduct) { - window.renderProductRating(currentProduct); - } + if (typeof window.renderProductRating === "function" && currentProduct) { + window.renderProductRating(currentProduct); + } - if (reviewSummary) { - reviewSummary.textContent = count - ? `${rating.toFixed(1)} average rating from ${count} review${count === 1 ? "" : "s"}` - : "No reviews yet. Be the first to review this product."; - } + if (reviewSummary) { + reviewSummary.textContent = count + ? `${rating.toFixed(1)} average rating from ${count} review${count === 1 ? "" : "s"}` + : "No reviews yet. Be the first to review this product."; } + } - function renderStars(rating = 0) { - const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0))); + function renderStars(rating = 0) { + const safeRating = Math.max( + 0, + Math.min(5, Math.round(Number(rating) || 0)), + ); - return Array.from({ length: 5 }, (_, index) => { - const className = index < safeRating ? "fas fa-star" : "far fa-star"; - return ``; - }).join(""); - } + return Array.from({ length: 5 }, (_, index) => { + const className = index < safeRating ? "fas fa-star" : "far fa-star"; + return ``; + }).join(""); + } - function setSelectedRating(value) { - selectedRating = Math.max(0, Math.min(5, Number(value) || 0)); + function setSelectedRating(value) { + selectedRating = Math.max(0, Math.min(5, Number(value) || 0)); - if (reviewRatingInput) { - reviewRatingInput.value = selectedRating ? String(selectedRating) : ""; - } + if (reviewRatingInput) { + reviewRatingInput.value = selectedRating ? String(selectedRating) : ""; + } - starButtons.forEach((button) => { - const rating = Number(button.dataset.rating); - const isActive = rating <= selectedRating; - const icon = button.querySelector("i"); + starButtons.forEach((button) => { + const rating = Number(button.dataset.rating); + const isActive = rating <= selectedRating; + const icon = button.querySelector("i"); - button.classList.toggle("is-active", isActive); - button.setAttribute("aria-pressed", String(rating === selectedRating)); + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(rating === selectedRating)); - if (icon) { - icon.className = isActive ? "fas fa-star" : "far fa-star"; - } - }); - } + if (icon) { + icon.className = isActive ? "fas fa-star" : "far fa-star"; + } + }); + } - function createReviewCard(review) { - const canDelete = isCurrentUserAdmin(); - const reviewId = Number(review.id); + function createReviewCard(review) { + const canDelete = isCurrentUserAdmin(); + const reviewId = Number(review.id); - return ` + return `
@@ -100,8 +103,8 @@
${ - canDelete && reviewId - ? `` - : "" + : "" }
@@ -120,192 +123,199 @@
`; - } + } - function renderReviews() { - if (!reviewContainer) { - return; - } + function renderReviews() { + if (!reviewContainer) { + return; + } - if (!productReviews.length) { - reviewContainer.innerHTML = ` + if (!productReviews.length) { + reviewContainer.innerHTML = `

No reviews yet

`; - return; - } - - reviewContainer.innerHTML = productReviews.map(createReviewCard).join(""); + return; } - async function loadProductReviews(productId) { - if (!productId) { - const urlParams = new URLSearchParams(window.location.search); - productId = urlParams.get("id"); - } - activeProductId = Number(productId); + reviewContainer.innerHTML = productReviews.map(createReviewCard).join(""); + } - if (!activeProductId || !reviewContainer) { - return; - } + async function loadProductReviews(productId) { + if (!productId) { + const urlParams = new URLSearchParams(window.location.search); + productId = urlParams.get("id"); + } + activeProductId = Number(productId); + + if (!activeProductId || !reviewContainer) { + return; + } - reviewContainer.innerHTML = ` + reviewContainer.innerHTML = `

Loading reviews...

`; - try { - const response = await AppUtils.apiRequest(`/products/${activeProductId}/reviews`); - - if (!response.success) { - throw new Error(response.message || "Failed to load reviews"); - } - - productReviews = AppUtils.safeArray(response.reviews); - updateRatingDisplay(response.averageRating, response.reviewCount); - renderReviews(); - } catch (error) { - console.error("LOAD REVIEWS ERROR:", error); - productReviews = []; - reviewContainer.innerHTML = ` + try { + const response = await AppUtils.apiRequest( + `/products/${activeProductId}/reviews`, + ); + + if (!response.success) { + throw new Error(response.message || "Failed to load reviews"); + } + + productReviews = AppUtils.safeArray(response.reviews); + updateRatingDisplay(response.averageRating, response.reviewCount); + renderReviews(); + } catch (error) { + console.error("LOAD REVIEWS ERROR:", error); + productReviews = []; + reviewContainer.innerHTML = `

Reviews could not be loaded right now.

`; - } } + } - async function submitReview(event) { - event.preventDefault(); + async function submitReview(event) { + event.preventDefault(); - if (!activeProductId) { - const urlParams = new URLSearchParams(window.location.search); - activeProductId = Number(urlParams.get("id")); - } + if (!activeProductId) { + const urlParams = new URLSearchParams(window.location.search); + activeProductId = Number(urlParams.get("id")); + } - if (!activeProductId || Number.isNaN(activeProductId)) { - AppUtils.notify("Product unavailable", "error"); - return; - } + if (!activeProductId || Number.isNaN(activeProductId)) { + AppUtils.notify("Product unavailable", "error"); + return; + } - if (!AppUtils.requireLogin("Please sign in to review this product")) { - return; - } + const user = AppUtils.requireAuth(); - const rating = Number(reviewRatingInput?.value || 0); - const comment = reviewMessageInput?.value.trim() || ""; + if (!user) { + return; + } - if (rating < 1 || rating > 5) { - AppUtils.notify("Choose a rating from 1 to 5 stars", "error"); - return; - } + const rating = Number(reviewRatingInput?.value || 0); + const comment = reviewMessageInput?.value.trim() || ""; - if (comment.length < 3 || comment.length > 1000) { - AppUtils.notify("Review comment must be between 3 and 1000 characters", "error"); - return; - } + if (rating < 1 || rating > 5) { + AppUtils.notify("Choose a rating from 1 to 5 stars", "error"); + return; + } - const submitButton = reviewForm.querySelector('button[type="submit"]'); - submitButton.disabled = true; - - try { - const response = await AppUtils.apiRequest( - `/products/${activeProductId}/review`, - { - method: "POST", - body: JSON.stringify({ - rating, - comment - }) - } - ); - - if (!response.success) { - throw new Error(response.message || "Failed to submit review"); - } - - AppUtils.notify("Review submitted successfully", "success"); - reviewForm.reset(); - setSelectedRating(0); - await loadProductReviews(activeProductId); - } catch (error) { - console.error("SUBMIT REVIEW ERROR:", error); - AppUtils.notify(error.message || "Failed to submit review", "error"); - } finally { - submitButton.disabled = false; - } + if (comment.length < 3 || comment.length > 1000) { + AppUtils.notify( + "Review comment must be between 3 and 1000 characters", + "error", + ); + return; } - async function deleteReview(reviewId) { - if (!activeProductId || !reviewId) { - return; - } + const submitButton = reviewForm.querySelector('button[type="submit"]'); + submitButton.disabled = true; + + try { + const response = await AppUtils.apiRequest( + `/products/${activeProductId}/review`, + { + method: "POST", + body: JSON.stringify({ + rating, + comment, + }), + }, + ); + + if (!response.success) { + throw new Error(response.message || "Failed to submit review"); + } + + AppUtils.notify("Review submitted successfully", "success"); + reviewForm.reset(); + setSelectedRating(0); + await loadProductReviews(activeProductId); + } catch (error) { + console.error("SUBMIT REVIEW ERROR:", error); + AppUtils.notify(error.message || "Failed to submit review", "error"); + } finally { + submitButton.disabled = false; + } + } - const confirmed = window.confirm("Delete this review?"); + async function deleteReview(reviewId) { + if (!activeProductId || !reviewId) { + return; + } - if (!confirmed) { - return; - } + const confirmed = window.confirm("Delete this review?"); - try { - const response = await AppUtils.apiRequest( - `/products/${activeProductId}/reviews/${reviewId}`, - { - method: "DELETE" - } - ); - - if (!response.success) { - throw new Error(response.message || "Failed to delete review"); - } - - AppUtils.notify("Review deleted", "success"); - updateRatingDisplay(response.averageRating, response.reviewCount); - await loadProductReviews(activeProductId); - } catch (error) { - console.error("DELETE REVIEW ERROR:", error); - AppUtils.notify(error.message || "Failed to delete review", "error"); - } + if (!confirmed) { + return; } - starButtons.forEach((button) => { - button.addEventListener("click", () => { - setSelectedRating(button.dataset.rating); - }); - - button.addEventListener("mouseenter", () => { - const hoverRating = Number(button.dataset.rating); - - starButtons.forEach((current) => { - const icon = current.querySelector("i"); - const isActive = Number(current.dataset.rating) <= hoverRating; - - if (icon) { - icon.className = isActive ? "fas fa-star" : "far fa-star"; - } - }); - }); - }); - - const starInput = document.querySelector(".review-star-input"); + try { + const response = await AppUtils.apiRequest( + `/products/${activeProductId}/reviews/${reviewId}`, + { + method: "DELETE", + }, + ); + + if (!response.success) { + throw new Error(response.message || "Failed to delete review"); + } + + AppUtils.notify("Review deleted", "success"); + updateRatingDisplay(response.averageRating, response.reviewCount); + await loadProductReviews(activeProductId); + } catch (error) { + console.error("DELETE REVIEW ERROR:", error); + AppUtils.notify(error.message || "Failed to delete review", "error"); + } + } - starInput?.addEventListener("mouseleave", () => { - setSelectedRating(selectedRating); + starButtons.forEach((button) => { + button.addEventListener("click", () => { + setSelectedRating(button.dataset.rating); }); - reviewForm?.addEventListener("submit", submitReview); + button.addEventListener("mouseenter", () => { + const hoverRating = Number(button.dataset.rating); - reviewContainer?.addEventListener("click", (event) => { - const deleteButton = event.target.closest(".review-delete-btn"); + starButtons.forEach((current) => { + const icon = current.querySelector("i"); + const isActive = Number(current.dataset.rating) <= hoverRating; - if (deleteButton) { - deleteReview(Number(deleteButton.dataset.reviewId)); + if (icon) { + icon.className = isActive ? "fas fa-star" : "far fa-star"; } + }); }); + }); + + const starInput = document.querySelector(".review-star-input"); + + starInput?.addEventListener("mouseleave", () => { + setSelectedRating(selectedRating); + }); + + reviewForm?.addEventListener("submit", submitReview); + + reviewContainer?.addEventListener("click", (event) => { + const deleteButton = event.target.closest(".review-delete-btn"); + + if (deleteButton) { + deleteReview(Number(deleteButton.dataset.reviewId)); + } + }); - window.loadProductReviews = loadProductReviews; - window.renderReviews = renderReviews; + window.loadProductReviews = loadProductReviews; + window.renderReviews = renderReviews; })();