diff --git a/backend/controllers/productController.js b/backend/controllers/productController.js index 91c7c83..69b273d 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 = ? `; @@ -429,7 +433,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]); @@ -443,7 +447,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); @@ -480,4 +484,4 @@ module.exports = { updateProduct, DeleteeProduct, getProductSuggestions -}; \ No newline at end of file +}; diff --git a/backend/controllers/reviewController.js b/backend/controllers/reviewController.js new file mode 100644 index 0000000..4dfa43a --- /dev/null +++ b/backend/controllers/reviewController.js @@ -0,0 +1,268 @@ +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 [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) + 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 a061776..e750e97 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"); // Require auth for all chat routes diff --git a/backend/routes/productRoutes.js b/backend/routes/productRoutes.js index 034c277..edcbfe1 100644 --- a/backend/routes/productRoutes.js +++ b/backend/routes/productRoutes.js @@ -10,6 +10,12 @@ const { getProductSuggestions } = require("../controllers/productController"); +const { + getProductReviews, + createProductReview, + deleteProductReview +} = require("../controllers/reviewController"); + const authMiddleware = require("../middleware/authMiddleware"); const { authorizeRoles } = require("../middleware/rbacMiddleware"); const { validateCreateProduct, validateUpdateProduct } = require("../middleware/validators/productValidator"); @@ -35,12 +41,18 @@ router.get("/status/check", (req, res) => { router.get("/search-suggestions", getProductSuggestions); router.get("/", getProducts); +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"), validateCreateProduct, createProduct); - router.put("/:id", authMiddleware, authorizeRoles("admin"), validateUpdateProduct, updateProduct); - router.delete("/:id", authMiddleware, authorizeRoles("admin"), DeleteeProduct); // Fallback diff --git a/backend/schema.sql b/backend/schema.sql index dabb856..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), @@ -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 9b54e2a..7a778f0 100644 --- a/frontend/product.html +++ b/frontend/product.html @@ -673,49 +673,86 @@

-
+

+ Loading reviews... +

- + - - + @@ -819,4 +856,4 @@

- \ No newline at end of file + diff --git a/frontend/scripts/auth.js b/frontend/scripts/auth.js index 912554f..838edab 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 || {} diff --git a/frontend/scripts/product-cards-home.js b/frontend/scripts/product-cards-home.js index b782ba2..e4afd59 100644 --- a/frontend/scripts/product-cards-home.js +++ b/frontend/scripts/product-cards-home.js @@ -35,7 +35,7 @@ function createProductCard(product, wishlistIds = null) { : AppUtils.getWishlist().some((item) => String(item.id) === String(product.id)); 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..0e73b2b 100644 --- a/frontend/scripts/product-reviews.js +++ b/frontend/scripts/product-reviews.js @@ -1,277 +1,321 @@ -// 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"), + ); + + 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 ""; + } -const reviewContainer = - document.getElementById( - "reviews-container" - ); + 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; + } -const reviewRatingInput = - document.getElementById( - "review-rating" - ); + 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."; + } + } -// 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 + function renderStars(rating = 0) { + const safeRating = Math.max( + 0, + Math.min(5, Math.round(Number(rating) || 0)), ); -} -// render single review -function createReviewCard( - review -) { + 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.name) - } -

+
+
+
+

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

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

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

+

${AppUtils.escapeHTML(review.comment)}

+ + + + `; + } + + function renderReviews() { + if (!reviewContainer) { + return; + } + + if (!productReviews.length) { + reviewContainer.innerHTML = ` +

+ No reviews yet +

+ `; + + return; + } + + reviewContainer.innerHTML = productReviews.map(createReviewCard).join(""); + } + + async function loadProductReviews(productId) { + if (!productId) { + const urlParams = new URLSearchParams(window.location.search); + productId = urlParams.get("id"); + } + activeProductId = Number(productId); - - ${ - review.date - } - -
- `; -} - -// render reviews -function renderReviews() { - if ( - !reviewContainer - ) { - return; + if (!activeProductId || !reviewContainer) { + return; } - if ( - !productReviews.length - ) { - reviewContainer.innerHTML = ` + 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. +

+ `; } + } + + async function submitReview(event) { + event.preventDefault(); - reviewContainer.innerHTML = - productReviews - .map( - createReviewCard - ) - .join(""); -} - -// average rating -function getAverageRating() { - if ( - !productReviews.length - ) { - return 0; + if (!activeProductId) { + const urlParams = new URLSearchParams(window.location.search); + activeProductId = Number(urlParams.get("id")); } - 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 || Number.isNaN(activeProductId)) { + AppUtils.notify("Product unavailable", "error"); + return; } - 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; + const user = AppUtils.requireAuth(); + + if (!user) { + return; } - const review = { - name, - message, - rating, - date: - new Date() - .toLocaleDateString() - }; - - productReviews.unshift( - review - ); + const rating = Number(reviewRatingInput?.value || 0); + const comment = reviewMessageInput?.value.trim() || ""; - saveProductReviews( - window.currentProductData.id - ); + if (rating < 1 || rating > 5) { + AppUtils.notify("Choose a rating from 1 to 5 stars", "error"); + return; + } - 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 (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, + }), + }, + ); + + 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; + } + } + + async function deleteReview(reviewId) { + if (!activeProductId || !reviewId) { + return; + } + + const confirmed = window.confirm("Delete this review?"); + + if (!confirmed) { + return; + } + + 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"); + } + } + + starButtons.forEach((button) => { + button.addEventListener("click", () => { + setSelectedRating(button.dataset.rating); + }); - document - .querySelectorAll( - ".review-star-input i" - ) - .forEach( - ( - current, - currentIndex - ) => { - current.style.color = - currentIndex <= index - ? "gold" - : "#ccc"; - } - ); - } - ); + 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"; } - ); + }); + }); + }); -// form listener -if ( - reviewForm -) { - reviewForm.addEventListener( - "submit", - submitReview - ); -} + const starInput = document.querySelector(".review-star-input"); -// expose globally -window.loadProductReviews = - loadProductReviews; + starInput?.addEventListener("mouseleave", () => { + setSelectedRating(selectedRating); + }); -window.renderReviews = - renderReviews; + reviewForm?.addEventListener("submit", submitReview); + + 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 0c33e24..93c473d 100644 --- a/frontend/scripts/product.js +++ b/frontend/scripts/product.js @@ -1,787 +1,330 @@ (() => { - - console.log( - "Product page loaded successfully!" - ); + console.log("Product page loaded successfully!"); // product page elements const productElements = { - - mainImage: - document.getElementById( - "main-product-image" - ), - - qtyInput: - document.getElementById( - "product-qty" - ), - - productCategory: - document.getElementById( - "product-category" - ), - - productName: - document.getElementById( - "product-name" - ), - - productPrice: - document.getElementById( - "product-price" - ), - - productOriginalPrice: - document.getElementById( - "product-original-price" - ), - - productDiscount: - document.getElementById( - "product-discount" - ), - - productBrand: - document.getElementById( - "product-brand" - ), - - productDescription: - document.getElementById( - "product-description" - ), - - productStock: - document.getElementById( - "product-stock" - ), - - variantStock: - document.getElementById( - "variant-stock" - ), - - wishlistBtn: - document.getElementById( - "wishlist-btn" - ), - - reviewForm: - document.getElementById( - "review-form" - ), - - plusBtn: - document.getElementById( - "plus-btn" - ), - - minusBtn: - document.getElementById( - "minus-btn" - ), - - addToCartBtn: - document.getElementById( - "add-to-cart-btn" - ), - - buyNowBtn: - document.getElementById( - "buy-now-btn" - ) + mainImage: document.getElementById("main-product-image"), + qtyInput: document.getElementById("product-qty"), + productCategory: document.getElementById("product-category"), + productName: document.getElementById("product-name"), + productPrice: document.getElementById("product-price"), + productOriginalPrice: document.getElementById("product-original-price"), + productDiscount: document.getElementById("product-discount"), + productBrand: document.getElementById("product-brand"), + productDescription: document.getElementById("product-description"), + productStock: document.getElementById("product-stock"), + variantStock: document.getElementById("variant-stock"), + wishlistBtn: document.getElementById("wishlist-btn"), + reviewForm: document.getElementById("review-form"), + plusBtn: document.getElementById("plus-btn"), + minusBtn: document.getElementById("minus-btn"), + addToCartBtn: document.getElementById("add-to-cart-btn"), + buyNowBtn: document.getElementById("buy-now-btn") }; // product state - let currentProductData = - null; + let currentProductData = null; // loading state - let isLoading = - false; + let isLoading = false; // product id - const urlParams = - new URLSearchParams( - window.location.search - ); - - const productId = - parseInt( - urlParams.get("id"), - 10 - ); + const urlParams = new URLSearchParams(window.location.search); + const productId = parseInt(urlParams.get("id"), 10); // invalid product id - if ( - Number.isNaN( - productId - ) - || - productId <= 0 - ) { - - window.location.href = - "shop.html"; - - throw new Error( - "Invalid product ID" - ); + if (Number.isNaN(productId) || productId <= 0) { + window.location.href = "shop.html"; + throw new Error("Invalid product ID"); } // escape html - function escapeHTML( - value - ) { - - return String( - value || "" - ) - - .replace( - /&/g, - "&" - ) - - .replace( - //g, - ">" - ) - - .replace( - /"/g, - """ - ) - - .replace( - /'/g, - "'" - ); + function escapeHTML(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } // safe quantity - function safeQty( - value - ) { - - return Math.max( - 1, - parseInt( - value, - 10 - ) || 1 - ); + function safeQty(value) { + return Math.max(1, parseInt(value, 10) || 1); } // fallback product function getFallbackProduct() { - return { - id: 1, - - brand: - "AnthropicBots", - - name: - "Nike Hoodie", - - category: - "Fashion", - + brand: "AnthropicBots", + name: "Nike Hoodie", + category: "Fashion", price: 2999, - - image: - "/assets/images/f1.jpg", - - description: - "Premium cotton hoodie with modern fashion styling and comfortable fit.", - + image: "/assets/images/f1.jpg", + description: "Premium cotton hoodie with modern fashion styling and comfortable fit.", stock: 12, - rating: 4.5, - discount_percent: 10 }; } - // loading state + // loading state toggles function showLoadingState() { - - document.body.classList.add( - "loading" - ); + document.body.classList.add("loading"); } function hideLoadingState() { - - document.body.classList.remove( - "loading" - ); + document.body.classList.remove("loading"); } // cache helpers function getCachedProduct() { - - return AppUtils.getJSON( - `product-${productId}`, - null - ); + return AppUtils.getJSON(`product-${productId}`, null); } - function cacheProduct( - product - ); -} - -// ======================================== -// Breadcrumb Navigation (Issue #344) -// ======================================== -function updateBreadcrumb(product) { - const categoryEl = document.getElementById('breadcrumb-category'); - const categoryLink = document.getElementById('breadcrumb-category-link'); - const productNameEl = document.getElementById('breadcrumb-product-name'); - - if (!product || !productNameEl) return; - - // Update product name - productNameEl.textContent = product.name || 'Product'; - - // Update category if available - if (product.category) { - categoryEl.style.display = 'inline-block'; - categoryLink.textContent = product.category.charAt(0).toUpperCase() + product.category.slice(1); - categoryLink.href = `shop.html?category=${encodeURIComponent(product.category)}`; - } else { - categoryEl.style.display = 'none'; + function cacheProduct(product) { + AppUtils.setJSON(`product-${productId}`, product); } -} - -// fetch product -async function fetchProduct() { - - if ( - isLoading - ) { - - AppUtils.setJSON( - `product-${productId}`, - product - ); - } - function saveRecentlyViewed( - product -) { - - if (!product) { - return; - } - - const recentlyViewed = - JSON.parse( - localStorage.getItem( - "recentlyViewed" - ) - ) || []; - - const filtered = - recentlyViewed.filter( - (item) => - Number(item.id) !== - Number(product.id) - ); - - filtered.unshift({ - id: product.id, - name: product.name, - price: product.price, - image: product.image - }); - - localStorage.setItem( - "recentlyViewed", - JSON.stringify( - filtered.slice(0, 10) - ) - ); -} - - // fetch product - async function fetchProduct() { - - if ( - isLoading - ) { - - return; - } - - isLoading = - true; - - showLoadingState(); - - try { - - const response = - await AppUtils.apiRequest( - `/products/${productId}` - ); - - if ( - response.success - && - response.product - ) { - - currentProductData = - response.product; - if ( - typeof saveRecentlyViewed === - "function" - ) { - saveRecentlyViewed( - response.product - ); - } - cacheProduct( - response.product - ); - - } else { - currentProductData = - getCachedProduct() - || - getFallbackProduct(); - } - - } catch (error) { + // Breadcrumb Navigation + function updateBreadcrumb(product) { + const categoryEl = document.getElementById('breadcrumb-category'); + const categoryLink = document.getElementById('breadcrumb-category-link'); + const productNameEl = document.getElementById('breadcrumb-product-name'); - console.error( - "PRODUCT FETCH ERROR:", - error - ); + if (!product || !productNameEl) return; - currentProductData = - getCachedProduct() - || - getFallbackProduct(); - - } finally { + productNameEl.textContent = product.name || 'Product'; - initializeProductPage(); - - hideLoadingState(); - - isLoading = - false; + if (product.category) { + categoryEl.style.display = 'inline-block'; + categoryLink.textContent = product.category.charAt(0).toUpperCase() + product.category.slice(1); + categoryLink.href = `shop.html?category=${encodeURIComponent(product.category)}`; + } else { + categoryEl.style.display = 'none'; } } - // Update breadcrumb - updateBreadcrumb(product); - - // out of stock - if ( - Number( - product.stock - ) <= 0 - ) { + // Recently viewed history + function saveRecentlyViewed(product) { + if (!product) return; - if ( - !product - ) { + const recentlyViewed = JSON.parse(localStorage.getItem("recentlyViewed")) || []; + const filtered = recentlyViewed.filter((item) => Number(item.id) !== Number(product.id)); - return; - } + filtered.unshift({ + id: product.id, + name: product.name, + price: product.price, + image: product.image + }); - // out of stock - if ( - Number( - product.stock - ) <= 0 - ) { + localStorage.setItem("recentlyViewed", JSON.stringify(filtered.slice(0, 10))); + } - if ( - productElements.addToCartBtn - ) { + // Primary Orchestrator for setting up page features post-fetch + function initializeProductPage(product) { + if (!product) return; - productElements.addToCartBtn.disabled = - true; + updateBreadcrumb(product); - productElements.addToCartBtn.innerText = - "Out of Stock"; + // Out of stock behavior handling + if (Number(product.stock) <= 0) { + if (productElements.addToCartBtn) { + productElements.addToCartBtn.disabled = true; + productElements.addToCartBtn.innerText = "Out of Stock"; } - - if ( - productElements.buyNowBtn - ) { - - productElements.buyNowBtn.disabled = - true; + if (productElements.buyNowBtn) { + productElements.buyNowBtn.disabled = true; } } - renderProduct( - product - ); + renderProduct(product); - if ( - typeof setupVariants === - "function" - ) { + if (typeof setupVariants === "function") { + setupVariants(product); + } - setupVariants( - product - ); + if (typeof setCurrentProduct === "function") { + setCurrentProduct(product); } - setCurrentProduct( - product - ); - setupCartActions( - product - ); + setupCartActions(product); - // clamp the quantity selector to this product's stock on load + // clamp quantity controls if (typeof window.syncProductQtyControls === "function") { window.syncProductQtyControls(); } - if ( - typeof loadProductReviews === - "function" - ) { - - loadProductReviews( - product.id - ); + if (typeof loadProductReviews === "function") { + loadProductReviews(product.id); } - if ( - typeof loadRelatedProducts === - "function" - ) { - - loadRelatedProducts( - product - ); + if (typeof loadRelatedProducts === "function") { + loadRelatedProducts(product); } - if ( - typeof loadRecentlyViewedRecommendations === - "function" - ) { - + if (typeof loadRecentlyViewedRecommendations === "function") { loadRecentlyViewedRecommendations(); } initializeImageZoom(); - - initializeProductGallery( - product - ); + initializeProductGallery(product); } - // add to cart - function addProductToCart( - product, - redirect = false - ) { + // fetch product data + async function fetchProduct() { + if (isLoading) return; + + isLoading = true; + showLoadingState(); - if ( - !product - ) { + try { + const response = await AppUtils.apiRequest(`/products/${productId}`); - return; + if (response && response.success && response.product) { + currentProductData = response.product; + if (typeof saveRecentlyViewed === "function") { + saveRecentlyViewed(currentProductData); + } + cacheProduct(currentProductData); + } else { + currentProductData = getCachedProduct() || getFallbackProduct(); + } + } catch (error) { + console.error("PRODUCT FETCH ERROR:", error); + currentProductData = getCachedProduct() || getFallbackProduct(); + } finally { + initializeProductPage(currentProductData); + hideLoadingState(); + isLoading = false; } + } + + // add to cart logic + function addProductToCart(product, redirect = false) { + if (!product) return; // cart is account-bound: guests must sign in first if (!AppUtils.requireLogin("Please sign in to add items to your cart")) { return; } - if ( - Number( - product.stock - ) <= 0 - ) { - - AppUtils.notify( - "Product is out of stock", - "error" - ); - + if (Number(product.stock) <= 0) { + AppUtils.notify("Product is out of stock", "error"); return; } - let cart = - AppUtils.getCart(); - - cart = - AppUtils.safeArray( - cart - ); - - const existing = - cart.find( - ( - item - ) => { - - return ( - Number( - item.id - ) === - Number( - product.id - ) - ); - } - ); - - const qty = - safeQty( - productElements.qtyInput - ?.value || 1 - ); - - if ( - existing - ) { - - existing.qty = - Math.min( - 10, - safeQty( - existing.qty - ) + qty - ); + let cart = AppUtils.getCart(); + cart = AppUtils.safeArray(cart); - } else { + const existing = cart.find((item) => Number(item.id) === Number(product.id)); + const qty = safeQty(productElements.qtyInput?.value || 1); + if (existing) { + existing.qty = Math.min(10, safeQty(existing.qty) + qty); + } else { cart.push({ - - id: - product.id, - - name: - product.name, - - price: - product.price, - - image: - product.image, - + id: product.id, + name: product.name, + price: product.price, + image: product.image, qty, - - stock: - product.stock + stock: product.stock }); } - AppUtils.saveCart( - cart - ); + AppUtils.saveCart(cart); + AppUtils.notify(`${product.name} added to cart`, "success"); - AppUtils.notify( - `${product.name} added to cart`, - "success" - ); - - if ( - typeof updateCartCount === - "function" - ) { + if (typeof loadProductReviews === "function") { + loadProductReviews(productId); + } + if (typeof updateCartCount === "function") { updateCartCount(); } - if ( - redirect - ) { - - window.location.href = - "cart.html"; + if (redirect) { + window.location.href = "cart.html"; } } - // setup cart actions - // NOTE: Add to Cart / Buy Now are handled by product-actions.js, which - // captures the selected size/color and validates against stock. Binding - // them here too caused every click to add the item twice (and fire two - // toasts), so this is intentionally a no-op. - function setupCartActions() { } - - // render product - function renderProduct( - product - ) { + // Intentionally left as a no-op to let product-actions.js handle click bindings natively + function setupCartActions(product) { } - if ( - !product - ) { - - return; - } + // render product interface elements + function renderProduct(product) { + if (!product) return; // image - if ( - productElements.mainImage - ) { - - productElements.mainImage.src = - escapeHTML( - product.image - || - "/assets/images/f1.jpg" - ); - - productElements.mainImage.alt = - escapeHTML( - product.name - || "Product" - ); - - productElements.mainImage.onerror = - () => { - - productElements.mainImage.src = - "/assets/images/f1.jpg"; - }; + if (productElements.mainImage) { + productElements.mainImage.src = escapeHTML(product.image || "/assets/images/f1.jpg"); + productElements.mainImage.alt = escapeHTML(product.name || "Product"); + productElements.mainImage.onerror = () => { + productElements.mainImage.src = "/assets/images/f1.jpg"; + }; } // category - if ( - productElements.productCategory - ) { - - productElements.productCategory.innerText = - product.category - || "Fashion"; + if (productElements.productCategory) { + productElements.productCategory.innerText = product.category || "Fashion"; } // name - if ( - productElements.productName - ) { - - productElements.productName.innerText = - product.name - || "Product Name"; + if (productElements.productName) { + productElements.productName.innerText = product.name || "Product Name"; } // price - if ( - productElements.productPrice - ) { - - productElements.productPrice.innerText = - AppUtils.formatPrice( - product.price || 0 - ); + if (productElements.productPrice) { + productElements.productPrice.innerText = AppUtils.formatPrice(product.price || 0); } // original price - if ( - productElements.productOriginalPrice - ) { - - const productPrice = - parseFloat( - product.price || 0 - ); - - const originalPrice = - productPrice + 1000; - - productElements.productOriginalPrice.innerText = - AppUtils.formatPrice( - originalPrice - ); + if (productElements.productOriginalPrice) { + const productPrice = parseFloat(product.price || 0); + const originalPrice = productPrice + 1000; + productElements.productOriginalPrice.innerText = AppUtils.formatPrice(originalPrice); } // discount - if ( - productElements.productDiscount - ) { - - productElements.productDiscount.innerText = - `${product.discount_percent - || 50 - }% OFF`; + if (productElements.productDiscount) { + productElements.productDiscount.innerText = `${product.discount_percent || 50}% OFF`; } // brand - if ( - productElements.productBrand - ) { - - productElements.productBrand.innerText = - product.brand - || "Fashion"; + if (productElements.productBrand) { + productElements.productBrand.innerText = product.brand || "Fashion"; } // description - if ( - productElements.productDescription - ) { - - productElements.productDescription.innerText = - product.description - || "Premium fashion product."; + if (productElements.productDescription) { + productElements.productDescription.innerText = product.description || "Premium fashion product."; } // stock - if ( - productElements.productStock - ) { - - productElements.productStock.innerText = - Number( - product.stock - ) > 0 - ? "In Stock" - : "Out Of Stock"; + if (productElements.productStock) { + productElements.productStock.innerText = Number(product.stock) > 0 ? "In Stock" : "Out Of Stock"; } // page title - document.title = - `${product.name} | AnthropicBots E-Commerce`; + document.title = `${product.name} | AnthropicBots E-Commerce`; } function initializeImageZoom() { - const mainImage = productElements.mainImage; - - if (!mainImage) { - return; - } + if (!mainImage) return; const container = document.getElementById("zoom-container"); - if (!container) { - return; - } - - // avoid duplicate listeners - if (mainImage.dataset.zoomReady) { - return; - } + if (!container) return; + if (mainImage.dataset.zoomReady) return; mainImage.dataset.zoomReady = "true"; container.addEventListener("mousemove", (e) => { const rect = container.getBoundingClientRect(); - - // Calculate cursor position as a percentage const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; @@ -795,64 +338,29 @@ async function fetchProduct() { }); } - // gallery - function initializeProductGallery( - product - ) { - - const thumbnails = - document.querySelectorAll( - ".small-image" - ); + function initializeProductGallery(product) { + const thumbnails = document.querySelectorAll(".small-image"); + if (!thumbnails.length) return; - if ( - !thumbnails.length - ) { - - return; - } - - thumbnails.forEach( - ( - thumb - ) => { - - thumb.src = - product.image - || - "/assets/images/f1.jpg"; - - thumb.onclick = - () => { - - if ( - productElements.mainImage - ) { - - productElements.mainImage.src = - thumb.src; - } - }; - } - ); + thumbnails.forEach((thumb) => { + thumb.src = product.image || "/assets/images/f1.jpg"; + thumb.onclick = () => { + if (productElements.mainImage) { + productElements.mainImage.src = thumb.src; + } + }; + }); } - // quantity controls - // read the live available stock (updated per variant in product-variants.js) function getStockCap() { const raw = productElements.variantStock ? parseInt(productElements.variantStock.innerText, 10) : NaN; - - // no numeric stock shown -> don't cap return isNaN(raw) ? Infinity : raw; } - // clamp the quantity to [1, stock] and enable/disable the +/- buttons function syncQtyControls() { - if (!productElements.qtyInput) { - return; - } + if (!productElements.qtyInput) return; const cap = getStockCap(); const qty = Math.max(1, Math.min(cap, safeQty(productElements.qtyInput.value))); @@ -868,123 +376,63 @@ async function fetchProduct() { } } - if ( - productElements.plusBtn - ) { - - productElements.plusBtn.addEventListener( - "click", - () => { - productElements.qtyInput.value = - safeQty(productElements.qtyInput.value) + 1; - syncQtyControls(); - } - ); + if (productElements.plusBtn) { + productElements.plusBtn.addEventListener("click", () => { + productElements.qtyInput.value = safeQty(productElements.qtyInput.value) + 1; + syncQtyControls(); + }); } - if ( - productElements.minusBtn - ) { - - productElements.minusBtn.addEventListener( - "click", - () => { - productElements.qtyInput.value = - safeQty(productElements.qtyInput.value) - 1; - syncQtyControls(); - } - ); + if (productElements.minusBtn) { + productElements.minusBtn.addEventListener("click", () => { + productElements.qtyInput.value = safeQty(productElements.qtyInput.value) - 1; + syncQtyControls(); + }); } - // expose so the variant switcher can re-clamp when stock changes window.syncProductQtyControls = syncQtyControls; // keyboard accessibility - document.addEventListener( - "keydown", - ( - event - ) => { - - const activeTag = - document.activeElement - ?.tagName; - - if ( - [ - "INPUT", - "TEXTAREA" - ].includes( - activeTag - ) - ) { - - return; - } + document.addEventListener("keydown", (event) => { + const activeTag = document.activeElement?.tagName; + if (["INPUT", "TEXTAREA"].includes(activeTag)) return; - if ( - event.key === "+" - && - productElements.plusBtn - ) { + if (event.key === "+" && productElements.plusBtn) { + productElements.plusBtn.click(); + } - productElements.plusBtn.click(); - } + if (event.key === "-" && productElements.minusBtn) { + productElements.minusBtn.click(); + } + }); - if ( - event.key === "-" - && - productElements.minusBtn - ) { + // Back to Top Button Implementation + function initBackToTop() { + const backToTopBtn = document.getElementById('back-to-top-btn'); + if (!backToTopBtn) return; - productElements.minusBtn.click(); + window.addEventListener('scroll', () => { + if (window.scrollY > 300) { + backToTopBtn.classList.add('show'); + backToTopBtn.style.display = 'flex'; + } else { + backToTopBtn.classList.remove('show'); + backToTopBtn.style.display = 'none'; } - } - ); - - // init - document.addEventListener( - "DOMContentLoaded", - () => { + }); - fetchProduct(); + backToTopBtn.addEventListener('click', () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + } - if ( - typeof updateCartCount === - "function" - ) { + // Master execution cycle once context is completely loaded + document.addEventListener("DOMContentLoaded", () => { + fetchProduct(); + initBackToTop(); - updateCartCount(); - } - } - ); - -// ======================================== -// Back to Top Button (Issue #345) -// ======================================== -function initBackToTop() { - const backToTopBtn = document.getElementById('back-to-top-btn'); - if (!backToTopBtn) return; - - // Show/hide button based on scroll position - window.addEventListener('scroll', () => { - if (window.scrollY > 300) { - backToTopBtn.classList.add('show'); - backToTopBtn.style.display = 'flex'; - } else { - backToTopBtn.classList.remove('show'); - backToTopBtn.style.display = 'none'; + if (typeof updateCartCount === "function") { + updateCartCount(); } }); - - // Smooth scroll to top on click - backToTopBtn.addEventListener('click', () => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - }); -} - -// Initialize after DOM is ready -document.addEventListener('DOMContentLoaded', () => { - initBackToTop(); -}); })(); \ No newline at end of file diff --git a/frontend/scripts/shop.js b/frontend/scripts/shop.js index 01910c2..14ad4a1 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/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 db7f5df..caed338 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; @@ -502,6 +574,7 @@ body.dark-theme .review-box p { color: #d1d1d1; } +body.dark-theme .reviews-summary, body.dark-theme .review-date { color: #aaaaaa; } @@ -525,6 +598,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; @@ -613,4 +701,3 @@ body.dark-theme .review-container p { margin-top: 8px; font-size: 14px; } -