From 78236a72f77da9baffa48d9ec5744aa9be027cc7 Mon Sep 17 00:00:00 2001 From: diapkwalve70 Date: Tue, 23 Jun 2026 11:04:05 +0530 Subject: [PATCH 1/3] feat(backend): implement schemas and controllers for product variants --- BACKEND/controllers/cart.controller.js | 39 ++ BACKEND/controllers/product.controller.js | 538 ++-------------------- BACKEND/models/cart.model.js | 15 + BACKEND/models/product.model.js | 91 +--- 4 files changed, 106 insertions(+), 577 deletions(-) create mode 100644 BACKEND/controllers/cart.controller.js create mode 100644 BACKEND/models/cart.model.js diff --git a/BACKEND/controllers/cart.controller.js b/BACKEND/controllers/cart.controller.js new file mode 100644 index 0000000..147e052 --- /dev/null +++ b/BACKEND/controllers/cart.controller.js @@ -0,0 +1,39 @@ +const Cart = require('../models/cart.model'); +const Product = require('../models/product.model'); + +exports.addToCart = async (req, res) => { + try { + const { productId, variantId, quantity } = req.body; + const userId = req.user._id; + + const product = await Product.findById(productId); + if (!product) return res.status(404).json({ message: "Product not found" }); + + if (product.hasVariants) { + const variant = product.variants.id(variantId); + if (!variant) return res.status(404).json({ message: "Variant not found" }); + if (variant.stock < quantity) return res.status(400).json({ message: "Insufficient variant stock" }); + } else { + if (product.baseStock < quantity) return res.status(400).json({ message: "Insufficient base stock" }); + } + + let cart = await Cart.findOne({ userId }); + if (!cart) cart = new Cart({ userId, items: [] }); + + const itemIndex = cart.items.findIndex(item => + item.productId.toString() === productId && + (!variantId || (item.variantId && item.variantId.toString() === variantId)) + ); + + if (itemIndex > -1) { + cart.items[itemIndex].quantity += quantity; + } else { + cart.items.push({ productId, variantId, quantity }); + } + + await cart.save(); + res.status(200).json(cart); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}; diff --git a/BACKEND/controllers/product.controller.js b/BACKEND/controllers/product.controller.js index 4e47a64..20556e9 100644 --- a/BACKEND/controllers/product.controller.js +++ b/BACKEND/controllers/product.controller.js @@ -1,508 +1,38 @@ -import Product from "../models/product.model.js"; -import mongoose from "mongoose"; -import { escapeRegex } from '../utils/escapeRegex.js'; -import cloudinary from '../config/cloudinary.js'; -import { AppError } from "../middleware/errorMiddleware.js"; -import { indexProduct, deleteProductFromIndex, searchProductsES } from '../services/elasticsearch.service.js'; -import redis from '../config/redis.js'; - -const CACHE_TTL = 300; // seconds - -function buildCacheKey(query) { - const sorted = Object.keys(query).sort().reduce((acc, k) => { acc[k] = query[k]; return acc; }, {}); - return `products:list:${JSON.stringify(sorted)}`; -} - -async function invalidateProductCache() { - if (!redis) return; - try { - const keys = await redis.keys('products:*'); - if (keys.length) await redis.del(...keys); - } catch (err) { - console.warn('[Redis] Cache invalidation error:', err.message); - } -} - -const cloudinaryConfigured = () => - process.env.CLOUDINARY_CLOUD_NAME && - process.env.CLOUDINARY_API_KEY && - process.env.CLOUDINARY_API_SECRET; - -const uploadToCloudinary = (buffer) => { - return new Promise((resolve, reject) => { - const stream = cloudinary.uploader.upload_stream( - { folder: 'product-store' }, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - stream.end(buffer); - }); -}; - -const extractCloudinaryPublicId = (url) => { - if (!url || !url.includes('res.cloudinary.com')) return null; - const parts = url.split('/'); - const uploadIdx = parts.indexOf('upload'); - if (uploadIdx === -1) return null; - const afterUpload = parts.slice(uploadIdx + 1); - if (afterUpload[0] && /^v\d+$/.test(afterUpload[0])) afterUpload.shift(); - return afterUpload.join('/').replace(/\.[^.]+$/, ''); -}; - -// @desc Get all products -export const getProducts = async (req, res, next) => { - try { - const page = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 10; - const { sort, category, minPrice, maxPrice, brand, minRating, inStock } = req.query; - - if (page < 1 || limit < 1) { - return res.status(400).json({ - success: false, - message: "Invalid pagination parameters. page and limit must be positive integers.", - }); - } - - // Check Redis cache first - const cacheKey = buildCacheKey(req.query); - if (redis) { - try { - const cached = await redis.get(cacheKey); - if (cached) { - return res.status(200).json(JSON.parse(cached)); - } - } catch (err) { - console.warn('[Redis] Cache read error:', err.message); - } - } - - let sortOption = {}; - if (sort === "price_asc") { - sortOption = { price: 1 }; - } else if (sort === "price_desc") { - sortOption = { price: -1 }; - } else if (sort === "newest") { - sortOption = { createdAt: -1 }; - } - - const filter = { isDeleted: { $ne: true } }; - if (category) filter.category = category; - - if (minPrice || maxPrice) { - filter.price = {}; - if (minPrice) filter.price.$gte = Number(minPrice); - if (maxPrice) filter.price.$lte = Number(maxPrice); - } - if (brand) { - filter.brand = { $regex: new RegExp(brand, 'i') }; - } - if (minRating) { - filter.averageRating = { $gte: Number(minRating) }; - } - if (inStock === 'true') { - filter.stock = { $gt: 0 }; - } - - const skip = (page - 1) * limit; - const totalProducts = await Product.countDocuments(filter); - const products = await Product.find(filter).sort(sortOption).skip(skip).limit(limit); - const totalPages = totalProducts > 0 ? Math.ceil(totalProducts / limit) : 0; - - const result = { - success: true, - currentPage: page, - totalPages, - totalProducts, - limit, - data: products, - }; - - // Store in Redis cache - if (redis) { - try { - await redis.set(cacheKey, JSON.stringify(result), 'EX', CACHE_TTL); - } catch (err) { - console.warn('[Redis] Cache write error:', err.message); - } - } - - res.status(200).json(result); - } catch (error) { - next(error); - } -}; - -// @desc Get distinct product categories -export const getProductCategories = async (req, res, next) => { - try { - const categories = await Product.distinct('category', { isDeleted: { $ne: true }, category: { $ne: '' } }); - res.status(200).json({ success: true, data: categories.sort() }); - } catch (error) { - next(error); - } -}; - -// @desc Create a new product -export const createProduct = async (req, res, next) => { - const { name, price, image: imageUrl, description, category, brand, stock, originalPrice, discount } = req.body; - - if (!name || price === undefined || price === null || price === '' || isNaN(Number(price))) { - return next(new AppError("Please provide all fields", 400)); - } - - if (Number(price) < 0) { - return next(new AppError("Price cannot be negative", 400)); - } - - let finalImageUrl = imageUrl || ''; - let cloudinaryPublicId; // track uploaded image's public ID for potential cleanup - - if (req.file) { - if (!cloudinaryConfigured()) { - return next(new AppError("File uploads are not configured. Please use an image URL instead.", 503)); - } - try { - const result = await uploadToCloudinary(req.file.buffer); - finalImageUrl = result.secure_url; - cloudinaryPublicId = result.public_id; // save public ID for cleanup if needed - } catch (_error) { - return next(new AppError("Image upload failed", 500)); - } - } - - if (!finalImageUrl) { - return next(new AppError("Please provide a product image", 400)); - } +const Product = require('../models/product.model'); +exports.createProduct = async (req, res) => { + try { + const { name, description, basePrice, baseStock, hasVariants, variants } = req.body; const newProduct = new Product({ - name, - price: Number(price), - image: finalImageUrl, - images: Array.isArray(req.body.images) ? req.body.images : [], - description, - category, - brand, - ...(stock !== undefined && { stock: Number(stock) }), - ...(originalPrice !== undefined && { originalPrice: Number(originalPrice) }), - ...(discount !== undefined && { discount: Number(discount) }), + name, + description, + basePrice: hasVariants ? undefined : basePrice, + baseStock: hasVariants ? undefined : baseStock, + hasVariants, + variants: hasVariants ? variants : [] }); - - try { - await newProduct.save(); - await indexProduct(newProduct); - await invalidateProductCache(); - res.status(201).json({ success: true, data: newProduct }); - } catch (error) { - // CLEANUP: delete uploaded Cloudinary image if save failed - if (cloudinaryPublicId) { - try { - await cloudinary.uploader.destroy(cloudinaryPublicId); - } catch (destroyError) { - console.error("Failed to delete Cloudinary image:", destroyError.message); - } - } - return next(error); - } -}; - -// @desc Update a product -export const updateProduct = async (req, res, next) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return next(new AppError("Invalid Product Id format", 404)); - } - - if ((!req.body || Object.keys(req.body).length === 0) && !req.file) { - return next(new AppError("No update fields provided", 400)); - } - - let existing; - try { - existing = await Product.findById(id); - } catch (error) { - return next(error); - } - if (!existing) { - return next(new AppError("Product not found", 404)); - } - - const { name, price, image: imageUrl, description, category, brand, stock, originalPrice, discount } = req.body; - const updateData = {}; - if (name !== undefined) updateData.name = name; - if (price !== undefined) { - if (price === '' || isNaN(Number(price))) { - return next(new AppError("Invalid price value", 400)); - } - updateData.price = Number(price); - } - if (imageUrl !== undefined) updateData.image = imageUrl; - if (req.body.images !== undefined) updateData.images = Array.isArray(req.body.images) ? req.body.images : []; - if (description !== undefined) updateData.description = description; - if (category !== undefined) updateData.category = category; - if (brand !== undefined) updateData.brand = brand; - if (stock !== undefined) updateData.stock = Number(stock); - if (originalPrice !== undefined) updateData.originalPrice = Number(originalPrice); - if (discount !== undefined) updateData.discount = Number(discount); - - if (req.file) { - if (!cloudinaryConfigured()) { - return next(new AppError("File uploads are not configured. Please use an image URL instead.", 503)); - } - try { - const result = await uploadToCloudinary(req.file.buffer); - updateData.image = result.secure_url; - } catch (_error) { - return next(new AppError("Image upload failed", 500)); - } - } - - try { - const updatedProduct = await Product.findByIdAndUpdate(id, updateData, { new: true, runValidators: true }); - if (!updatedProduct) { - return next(new AppError("Product not found", 404)); - } - if (req.file) { - const oldPublicId = extractCloudinaryPublicId(existing.image); - if (oldPublicId) { - cloudinary.uploader.destroy(oldPublicId).catch((err) => { - console.warn("Old image cleanup failed:", err.message); - }); - } - } - - await indexProduct(updatedProduct); - await invalidateProductCache(); - - res.status(200).json({ success: true, data: updatedProduct }); - } catch (error) { - next(error); - } -}; - -// @desc Restock a product by incrementing its stock -export const restockProduct = async (req, res, next) => { - const { id } = req.params; - const { amount } = req.body; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return next(new AppError("Invalid Product Id format", 404)); - } - - if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) { - return next(new AppError("Restock amount must be a positive integer", 400)); - } - - try { - const product = await Product.findOneAndUpdate( - { _id: id, isDeleted: { $ne: true } }, - { $inc: { stock: amount } }, - { new: true, runValidators: true } - ); - if (!product) return next(new AppError("Product not found", 404)); - res.status(200).json({ success: true, data: product }); - } catch (error) { - next(error); - } -}; - -// @desc Delete a product (soft delete) -export const deleteProduct = async (req, res, next) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return next(new AppError("Invalid Product Id format", 404)); - } - - try { - const product = await Product.findByIdAndUpdate(id, { isDeleted: true }, { new: true }); - if (!product) { - return next(new AppError("Product not found", 404)); - } - await deleteProductFromIndex(id); - await invalidateProductCache(); - res.status(200).json({ success: true, message: "Product deleted successfully" }); - } catch (error) { - next(error); - } -}; - -// @desc Get product by ID -export const getProductById = async (req, res, next) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return next(new AppError("Invalid Product Id format", 404)); - } - - try { - const product = await Product.findOne({ _id: id, isDeleted: { $ne: true } }); - if (!product) { - return next(new AppError("Product not found", 404)); - } - res.status(200).json({ success: true, data: product }); - } catch (error) { - next(error); - } -}; - -const stopWords = new Set(["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "with", "of"]); - -function tokenize(text) { - return text - .toLowerCase() - .split(/\s+/) - .map(w => w.replace(/[^a-z0-9]/g, "")) - .filter(w => w.length > 1 && !stopWords.has(w)); -} - -export const getRelatedProducts = async (req, res) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ success: false, message: "Invalid Product Id format" }); - } - - try { - const product = await Product.findById(id); - - if (!product || product.isDeleted === true) { - return res.status(404).json({ success: false, message: "Product not found" }); - } - - const targetTagsSet = new Set((product.tags || []).map(t => t.toLowerCase())); - const targetWords = new Set(tokenize(product.name)); - - const orConditions = []; - if (product.category) orConditions.push({ category: product.category }); - if (product.brand) orConditions.push({ brand: product.brand }); - if (targetTagsSet.size > 0) orConditions.push({ tags: { $in: [...targetTagsSet] } }); - - const query = { - _id: { $ne: product._id }, - isDeleted: { $ne: true }, - }; - if (orConditions.length > 0) query.$or = orConditions; - - const candidates = await Product.find(query).sort({ updatedAt: -1 }).limit(50); - - const scored = candidates.map(c => { - let score = 0; - - if (c.category && product.category && - c.category.toLowerCase() === product.category.toLowerCase()) { - score += 3; - } - - if (c.brand && product.brand && - c.brand.toLowerCase() === product.brand.toLowerCase()) { - score += 1; - } - - if (c.tags && c.tags.length > 0) { - for (const tag of c.tags) { - if (targetTagsSet.has(tag.toLowerCase())) { - score += 2; - } - } - } - - const candidateWords = tokenize(c.name); - for (const word of candidateWords) { - if (targetWords.has(word)) { - score += 0.5; - } - } - - return { product: c, score }; - }); - - scored.sort((a, b) => b.score - a.score); - - const related = scored.slice(0, 5).map(s => s.product); - - res.status(200).json({ success: true, data: related }); - } catch (error) { - console.error("Error in getRelatedProducts:", error.message); - res.status(500).json({ success: false, message: "Server Error" }); - } -}; - -export const getProductBundle = async (req, res) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ success: false, message: "Invalid Product Id" }); - } - - try { - const product = await Product.findById(id).populate('complementaryItems.product'); - if (!product || product.isDeleted === true) { - return res.status(404).json({ success: false, message: "Product not found" }); - } - - const items = product.complementaryItems - .filter(ci => ci.product && !ci.product.isDeleted) - .slice(0, 3); - - const bundleTotal = [product, ...items.map(i => i.product)] - .reduce((sum, p) => sum + (Number(p?.price) || 0), 0); - - const bundleDiscount = 0.1; - const bundlePrice = bundleTotal > 0 - ? +(bundleTotal * (1 - bundleDiscount)).toFixed(2) - : 0; - const savings = bundleTotal > 0 - ? +(bundleTotal * bundleDiscount).toFixed(2) - : 0; - - res.status(200).json({ - success: true, - data: { - mainProduct: product, - items: items.map(ci => ({ - product: ci.product, - reason: ci.reason - })), - bundleTotal, - bundleDiscount, - bundlePrice, - savings - } - }); - } catch (error) { - console.error("Error in fetching bundle:", error.message); - res.status(500).json({ success: false, message: "Server Error" }); - } -}; - -// @desc Search products -export const searchProducts = async (req, res, next) => { - const { q } = req.query; - - if (!q || !q.trim()) { - return res.status(400).json({ success: false, message: "Search query is required" }); - } - - try { - const esProducts = await searchProductsES(q); - if (esProducts) { - return res.status(200).json({ success: true, data: esProducts }); - } - - const safeQuery = escapeRegex(q); - const regex = new RegExp(safeQuery, 'i'); - const products = await Product.find({ - $or: [ - { name: regex }, - { tags: { $in: [regex] } } - ], - isDeleted: { $ne: true } - }); - res.status(200).json({ success: true, data: products }); - } catch (error) { - next(error); - } + await newProduct.save(); + res.status(201).json(newProduct); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}; + +exports.getProducts = async (req, res) => { + try { + const products = await Product.find(); + res.status(200).json(products); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}; + +exports.getProductById = async (req, res) => { + try { + const product = await Product.findById(req.params.id); + if (!product) return res.status(404).json({ message: "Product not found" }); + res.status(200).json(product); + } catch (error) { + res.status(500).json({ message: error.message }); + } }; diff --git a/BACKEND/models/cart.model.js b/BACKEND/models/cart.model.js new file mode 100644 index 0000000..2c7bfa1 --- /dev/null +++ b/BACKEND/models/cart.model.js @@ -0,0 +1,15 @@ +const mongoose = require('mongoose'); + +const cartItemSchema = new mongoose.Schema({ + productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true }, + variantId: { type: mongoose.Schema.Types.ObjectId }, + quantity: { type: Number, required: true, min: 1 } +}); + +const cartSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + items: [cartItemSchema] +}, { timestamps: true }); + +const Cart = mongoose.model('Cart', cartSchema); +module.exports = Cart; diff --git a/BACKEND/models/product.model.js b/BACKEND/models/product.model.js index a34b374..4fef889 100644 --- a/BACKEND/models/product.model.js +++ b/BACKEND/models/product.model.js @@ -1,76 +1,21 @@ -import mongoose from "mongoose"; +const mongoose = require('mongoose'); -const productSchema = new mongoose.Schema({ - // Required Fields - name:{ - type: String, - required: [true, "Product name is required"], // Custom error message - trim: true, - minlength: [3, "Product name must be at least 3 characters long"], - maxlength: [100, "Product name cannot exceed 100 characters"], - }, - - price: { - type: Number, - required: [true, "Price is required"], - min: [0, "Price cannot be negative"], - max: [1000000, "Price cannot exceed 1,000,000"], - validate: { - validator: function (value) { - return value !== null && value !== undefined && !isNaN(value); - }, - message: "Price must be a valid number", - }, - }, - - image: { - type: String, - required: [true, "Image URL is required"], - trim: true, - }, - images: { - type: [String], - default: [], - }, - - // Optional Fields - Extra Product Details - description: { - type: String, - trim: true, - default: '' - }, - category: { - type: String, - trim: true, - default: '' - }, - brand: { - type: String, - trim: true, - default: '' - }, - tags: { - type: [String], - default: [] - }, - stock: { - type: Number, - min: [0, 'Stock cannot be negative'], - default: 0 - }, - tags:{ - type: [String], - default: [], - validate: { - validator: function (tags){ - return tags.length <= 5 && tags.every(tag => tag.length >= 2 && tag.length <= 30); - }, - message: "Maximum 5 tags, each 2-30 characters" - } - } -},{ - timestamps: true +const variantSchema = new mongoose.Schema({ + size: { type: String, required: true }, + color: { type: String, required: true }, + price: { type: Number, required: true }, + stock: { type: Number, required: true, default: 0 }, + images: [{ type: String }] }); -const Product = mongoose.model("Product", productSchema); -export default Product; \ No newline at end of file +const productSchema = new mongoose.Schema({ + name: { type: String, required: true }, + description: { type: String, required: true }, + basePrice: { type: Number }, + baseStock: { type: Number }, + hasVariants: { type: Boolean, default: false }, + variants: [variantSchema] +}, { timestamps: true }); + +const Product = mongoose.model('Product', productSchema); +module.exports = Product; From 029a47eb384b757eb7ebd4367b387c2f3f17e0a3 Mon Sep 17 00:00:00 2001 From: diapkwalve70 Date: Tue, 23 Jun 2026 11:07:40 +0530 Subject: [PATCH 2/3] feat(frontend): add dynamic size and color selectors to product page --- FRONTEND/src/pages/ProductPage.jsx | 684 +++-------------------------- 1 file changed, 63 insertions(+), 621 deletions(-) diff --git a/FRONTEND/src/pages/ProductPage.jsx b/FRONTEND/src/pages/ProductPage.jsx index 6942c24..78738cd 100644 --- a/FRONTEND/src/pages/ProductPage.jsx +++ b/FRONTEND/src/pages/ProductPage.jsx @@ -1,645 +1,87 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, Link as RouterLink } from 'react-router-dom'; -import { - Box, Container, Flex, Image, Heading, Text, Button, - Spinner, Alert, AlertIcon, VStack, HStack, useColorModeValue, - useToast, Badge, Divider, Icon, Grid, GridItem, SimpleGrid, Checkbox -} from '@chakra-ui/react'; -import { FaArrowLeft, FaShoppingCart, FaCheckCircle, FaTruck, FaShieldAlt, FaUndo, FaInfoCircle, FaGift, FaChevronLeft, FaChevronRight } from 'react-icons/fa'; -import { useCart } from '../store/cart.js'; -import { useRecentlyViewed } from "../store/product"; -import Breadcrumbs from "../components/ui/Breadcrumbs"; -import RelatedProducts from '../components/ui/RelatedProducts'; -import ProductReviews from '../components/ui/ProductReviews'; - -const API = ( import.meta.env.VITE_API_URL || "" ).replace( /\/$/, "" ); +import React, { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { Box, Heading, Text, Select, Button, VStack, HStack, useToast } from '@chakra-ui/react'; +import axios from 'axios'; const ProductPage = () => { const { id } = useParams(); - const [product, setProduct] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [quantity, setQuantity] = useState(1); - const [bundleData, setBundleData] = useState(null); - const [selectedBundleItems, setSelectedBundleItems] = useState([]); - const [activeImg, setActiveImg] = useState(0); - - const { addToCart, addBundleToCart } = useCart(); - - const { addRecentlyViewed } = useRecentlyViewed(); const toast = useToast(); - - const textColor = useColorModeValue("gray.700", "gray.300"); - const priceColor = useColorModeValue("blue.600", "blue.300"); - const borderCol = useColorModeValue("gray.200", "gray.700"); - const cardBg = useColorModeValue("white", "gray.800"); - const featureBg = useColorModeValue("gray.50", "gray.700"); - const infoColor = useColorModeValue("gray.700", "gray.300"); - - const LOW_STOCK_THRESHOLD = 5; - const hasStock = product && product.stock !== undefined && product.stock !== null; - const isOutOfStock = hasStock && product.stock === 0; - const isLowStock = hasStock && product.stock > 0 && product.stock <= LOW_STOCK_THRESHOLD; - const maxQty = hasStock && product.stock > 0 ? Math.min(product.stock, 10) : 10; + const [product, setProduct] = useState(null); + const [selectedSize, setSelectedSize] = useState(''); + const [selectedColor, setSelectedColor] = useState(''); + const [displayPrice, setDisplayPrice] = useState(0); + const [displayStock, setDisplayStock] = useState(0); + const [selectedVariantId, setSelectedVariantId] = useState(null); useEffect(() => { const fetchProduct = async () => { - setLoading(true); - setError(null); try { - const url = `${API}/api/products/${id}`; - const res = await fetch(url); - - if (!res.ok) { - if (res.status === 404) { - throw new Error("Product not found. It may have been deleted or the link is invalid."); - } else if (res.status === 500) { - throw new Error("Server error. Please try again later."); - } else { - throw new Error(`HTTP ${res.status}: Failed to fetch product`); - } - } - - const data = await res.json(); - - if (data.success) { - setProduct(data.data); - setActiveImg(0); - addRecentlyViewed(data.data); - } else { - throw new Error(data.message || "Failed to fetch product details"); + const { data } = await axios.get('/api/products/' + id); + setProduct(data); + if (!data.hasVariants) { + setDisplayPrice(data.basePrice); + setDisplayStock(data.baseStock); } } catch (err) { - setError(err.message); - } finally { - setLoading(false); + console.error(err); } }; - - if (id) { - fetchProduct(); - } - }, [id, addRecentlyViewed]); + fetchProduct(); + }, [id]); useEffect(() => { - if (!id) return; - const fetchBundle = async () => { - try { - const res = await fetch(`${API}/api/products/${id}/bundle`); - if (!res.ok) { - console.error("Failed to fetch bundle, status:", res.status); - setBundleData(null); - return; - } - const data = await res.json(); - if (data.success && data.data && data.data.items.length > 0) { - setBundleData(data.data); - setSelectedBundleItems(data.data.items.map(i => i.product._id)); - } - } catch (err) { - console.error("Error fetching bundle:", err); - setBundleData(null); + if (product && product.hasVariants && selectedSize && selectedColor) { + const matched = product.variants.find(v => v.size === selectedSize && v.color === selectedColor); + if (matched) { + setDisplayPrice(matched.price); + setDisplayStock(matched.stock); + setSelectedVariantId(matched._id); + } else { + setDisplayStock(0); + setSelectedVariantId(null); } - }; - fetchBundle(); - }, [id]); - - const handleAddToCart = () => { - if (!product || isOutOfStock) return; - const { status, added } = addToCart(product, quantity); - if (added === 0) { - toast({ - title: "Stock limit reached", - description: `You already have the maximum available stock of ${product.name} in your cart.`, - status: "warning", - duration: 2500, - isClosable: true, - position: "top-right", - }); - return; - } - if (status === 'capped') { - toast({ - title: "Stock limit reached", - description: `Only ${added} item${added !== 1 ? 's were' : ' was'} added — you've reached the available stock for ${product.name}.`, - status: "warning", - duration: 2500, - isClosable: true, - position: "top-right", - }); - return; - } - toast({ - title: "Added to Cart", - description: `${added} x ${product.name} added to your cart.`, - status: "success", - duration: 2500, - isClosable: true, - position: "top-right", - }); - }; - - const handleAddBundleToCart = () => { - const isFullBundle = selectedBundleItems.length === bundleData.items.length; - const discount = isFullBundle ? bundleData.bundleDiscount : 0; - const allItems = [product, ...bundleData.items - .filter(i => selectedBundleItems.includes(i.product._id)) - .map(i => i.product)]; - const { addedCount, skippedCount } = addBundleToCart(allItems, discount); - - if (addedCount > 0) { - toast({ - title: "Bundle Added!", - description: `${addedCount} item${addedCount !== 1 ? 's' : ''} added to your cart.`, - status: "success", - duration: 2500, - isClosable: true, - position: "top-right", - }); - return; } + }, [selectedSize, selectedColor, product]); - if (skippedCount > 0) { - toast({ - title: "Stock limit reached", - description: `${skippedCount} item${skippedCount !== 1 ? 's' : ''} couldn't be added due to stock limits.`, - status: "warning", - duration: 3500, - isClosable: true, - position: "top-right", - }); + const handleAddToCart = async () => { + try { + if (product.hasVariants && !selectedVariantId) { + toast({ title: 'Please select valid options', status: 'warning' }); + return; + } + await axios.post('/api/cart', { productId: product._id, variantId: selectedVariantId, quantity: 1 }); + toast({ title: 'Added to cart!', status: 'success' }); + } catch (err) { + toast({ title: 'Error adding to cart', status: 'error' }); } }; - const toggleBundleItem = (productId) => { - setSelectedBundleItems(prev => - prev.includes(productId) - ? prev.filter(id => id !== productId) - : [...prev, productId] - ); - }; - - if (loading) { - return ( - - - Loading product details... - - ); - } - - if (error || !product) { - return ( - - - - - Error Loading Product - {error || "Product not found or has been removed."} - - - - - ); - } - -const allImages = [product?.image, ...(product?.images || [])].filter(Boolean); + if (!product) return Loading...; return ( - <> - - - - - - - {/* Product Image Section */} - - - - {`${product.name} - {allImages.length > 1 && ( - <> - - - - )} - - {allImages.length > 1 && ( - - {allImages.map((img, idx) => ( - setActiveImg(idx)} - borderRadius="md" overflow="hidden" border="2px solid" - borderColor={activeImg === idx ? "blue.400" : borderCol} - w="60px" h="60px" transition="all 0.2s" - _hover={{ borderColor: "blue.300" }} - aria-label={`View image ${idx + 1}`} - > - {`${product.name} - - ))} - - )} - - - - {/* Product Details Section */} - - - {/* Product Title */} - - {/* Stock Badge - Only show if stock data exists */} - {product.stock !== undefined && product.stock !== null && ( - <> - - {product.stock === 0 - ? "Out of Stock" - : isLowStock - ? `Low Stock — Only ${product.stock} left!` - : `In Stock (${product.stock} available)`} - - {isLowStock && ( - - - - Hurry! Only {product.stock} item{product.stock !== 1 ? "s" : ""} left in stock — order soon before it sells out. - - - )} - - )} - - - {product.name} - - - {/* Average Rating */} - {product.reviewCount > 0 && ( - - {[1,2,3,4,5].map(s => ( - - ★ - - ))} - - {product.averageRating} ({product.reviewCount} {product.reviewCount === 1 ? 'review' : 'reviews'}) - - - )} - - {/* Category & Brand - Only show if exists */} - - {product.category && ( - - {product.category} - - )} - {product.brand && ( - - Brand: {product.brand} - - )} - - - - {/* Price */} - - - ${product.price} - - - {/* Show original price and discount only if data exists */} - {product.originalPrice && product.originalPrice > product.price && ( - <> - - ${product.originalPrice} - - - {product.discount || Math.round(((product.originalPrice - product.price) / product.originalPrice) * 100)}% OFF - - - )} - - - - - {/* Description */} - - - Product Description - - {product.description && product.description.trim() ? ( - - {product.description} - - ) : ( - - - - - No detailed description available for this product yet. Check back later for more information. - - - - )} - - - - - {/* Quantity Selector */} - - Quantity - - - - {quantity} - - - - - - {/* Add to Cart Button */} - - - {/* Features Grid */} - - - - - - - - - - - {/* Frequently Bought Together */} - {bundleData && bundleData.items.length > 0 && ( - - - - - - Frequently Bought Together - - - - - - - - {product.name} - - {product.name} - ${product.price} - - - - {bundleData.items.map((ci) => ( - - toggleBundleItem(ci.product._id)} - size="lg" - colorScheme="blue" - /> - {ci.product.name} - - {ci.product.name} - ${ci.product.price} - {ci.reason && ( - - {ci.reason} - - )} - - - ))} - - - - - - - - Bundle Total:{' '} - - ${bundleData.bundleTotal} - - - - ${(() => { - const selectedTotal = [product, ...bundleData.items - .filter(i => selectedBundleItems.includes(i.product._id)) - .map(i => i.product)] - .reduce((sum, p) => sum + p.price, 0); - const isFullBundle = selectedBundleItems.length === bundleData.items.length; - const discount = isFullBundle - ? bundleData.bundleDiscount - : 0; - return (selectedTotal * (1 - discount)).toFixed(2); - })()} - - {selectedBundleItems.length === bundleData.items.length && ( - - Save ${bundleData.savings} ({Math.round(bundleData.bundleDiscount * 100)}% off) - - )} - - - - - - )} - - {/* Reviews Section */} - - - {/* Related Products Section */} - - - + + {product.name} + {product.description} + $ {displayPrice} + {displayStock > 0 ? 'In Stock: ' + displayStock : 'Out of Stock'} + {product.hasVariants && ( + + + Size: + + + + Color: + + + + )} + + ); }; - -// Feature Box Component -const FeatureBox = ({ icon, title, desc, bg }) => { - const textColor = useColorModeValue("gray.700", "gray.300"); - - return ( - - - - {title} - {desc} - - - ); -}; - -export default ProductPage; +export default ProductPage; \ No newline at end of file From 10e2f69ab3a07dcf1f388f94206769b0b396a2f3 Mon Sep 17 00:00:00 2001 From: diapkwalve70 Date: Tue, 23 Jun 2026 11:09:55 +0530 Subject: [PATCH 3/3] feat(frontend): update cart store to support variantId payload tracking --- FRONTEND/src/store/cart.js | 139 ++++++++++--------------------------- 1 file changed, 38 insertions(+), 101 deletions(-) diff --git a/FRONTEND/src/store/cart.js b/FRONTEND/src/store/cart.js index a1e3422..dc6ccb7 100644 --- a/FRONTEND/src/store/cart.js +++ b/FRONTEND/src/store/cart.js @@ -1,103 +1,40 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -export const useCartStore = create( - persist( - (set) => ({ - cartItems: [], - - addToCart: (product, quantity = 1) => { - const stockTracked = product.stock != null; - if (stockTracked && product.stock === 0) return { status: 'out_of_stock', added: 0 }; - - let status = 'added'; - let added = 0; - - set((state) => { - const existingItem = state.cartItems.find((item) => item._id === product._id && !item.bundleId); - const currentQty = existingItem ? existingItem.quantity : 0; - - let canAdd = quantity; - if (stockTracked) { - const available = product.stock - currentQty; - if (available <= 0) { - status = 'capped'; - added = 0; - return state; - } - canAdd = Math.min(quantity, available); - if (canAdd < quantity) status = 'capped'; - } - added = canAdd; - - if (existingItem) { - return { - cartItems: state.cartItems.map((item) => - item._id === product._id ? { ...item, quantity: item.quantity + canAdd } : item - ), - }; - } - return { cartItems: [...state.cartItems, { ...product, quantity: canAdd }] }; - }); - - return { status, added }; - }, - - removeFromCart: (id) => { - set((state) => ({ - cartItems: state.cartItems.filter((item) => item._id !== id), - })); - }, - - addBundleToCart: (items, discount = 0) => { - let addedCount = 0; - let skippedCount = 0; - const bundleId = `bundle-${items.map((i) => i._id).sort().join('-')}`; - set((state) => { - const updated = [...state.cartItems]; - for (const item of items) { - const stockTracked = item.stock != null; - const idx = updated.findIndex((i) => i._id === item._id && i.bundleId === bundleId); - const currentQty = idx >= 0 ? updated[idx].quantity : 0; - - if (stockTracked) { - const available = item.stock - currentQty; - if (available <= 0) { - skippedCount++; - continue; - } - } - - if (idx >= 0) { - updated[idx] = { ...updated[idx], quantity: updated[idx].quantity + 1 }; - } else { - updated.push({ ...item, quantity: 1, bundleId, discountApplied: discount }); - } - addedCount++; - } - return { cartItems: updated }; - }); - return { addedCount, skippedCount }; - }, - emptyCart: () => set({ cartItems: [] }), - }), - { - name: 'productStoreCart', +import axios from 'axios'; + +export const useCartStore = create((set, get) => ({ + cart: null, + loading: false, + + fetchCart: async () => { + set({ loading: true }); + try { + const response = await axios.get('/api/cart'); + set({ cart: response.data, loading: false }); + } catch (error) { + set({ loading: false }); + console.error('Error fetching cart:', error); } - ) -); - -export const useCart = () => { - const cartItems = useCartStore((state) => state.cartItems); - const addToCart = useCartStore((state) => state.addToCart); - const removeFromCart = useCartStore((state) => state.removeFromCart); - const addBundleToCart = useCartStore((state) => state.addBundleToCart); - const emptyCart = useCartStore((state) => state.emptyCart); - - const totalPrice = cartItems.reduce((total, item) => { - const effectivePrice = item.discountApplied ? item.price * (1 - item.discountApplied) : item.price; - return total + effectivePrice * item.quantity; - }, 0); - - return { cartItems, addToCart, removeFromCart, addBundleToCart, emptyCart, totalPrice }; -}; + }, + + addToCart: async (productId, variantId = null, quantity = 1) => { + set({ loading: true }); + try { + const response = await axios.post('/api/cart', { productId, variantId, quantity }); + set({ cart: response.data, loading: false }); + } catch (error) { + set({ loading: false }); + console.error('Error adding to cart:', error); + } + }, + + removeFromCart: async (productId, variantId = null) => { + set({ loading: true }); + try { + const response = await axios.delete('/api/cart', { data: { productId, variantId } }); + set({ cart: response.data, loading: false }); + } catch (error) { + set({ loading: false }); + console.error('Error removing from cart:', error); + } + } +})); \ No newline at end of file