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;
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."}
-
-
- }
- size="lg"
- >
- Back to Products
-
-
- );
- }
-
-const allImages = [product?.image, ...(product?.images || [])].filter(Boolean);
+ if (!product) return Loading...;
return (
- <>
-
-
-
- }
- mb={6}
- size="sm"
- _hover={{ bg: featureBg }}
- >
- Back to Products
-
-
-
- {/* Product Image Section */}
-
-
-
-
- {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 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 */}
- }
- isDisabled={isOutOfStock}
- boxShadow="lg"
- _hover={{
- transform: isOutOfStock ? "none" : "translateY(-3px)",
- boxShadow: isOutOfStock ? "lg" : "2xl",
- }}
- _active={{ transform: "translateY(0)" }}
- transition="all 0.2s"
- >
- {isOutOfStock ? "Out of Stock" : `Add ${quantity > 1 ? `${quantity} items` : ''} to Cart`}
-
-
- {/* Features Grid */}
-
-
-
-
-
-
-
-
-
-
- {/* Frequently Bought Together */}
- {bundleData && bundleData.items.length > 0 && (
-
-
-
-
-
- Frequently Bought Together
-
-
-
-
-
-
-
-
-
- {product.name}
- ${product.price}
-
-
-
- {bundleData.items.map((ci) => (
-
- toggleBundleItem(ci.product._id)}
- size="lg"
- colorScheme="blue"
- />
-
-
- {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)
-
- )}
-
- }
- onClick={handleAddBundleToCart}
- isDisabled={selectedBundleItems.length === 0}
- boxShadow="lg"
- _hover={{ transform: "translateY(-2px)", boxShadow: "xl" }}
- _active={{ transform: "translateY(0)" }}
- transition="all 0.2s"
- >
- Add Bundle to Cart
-
-
-
-
- )}
-
- {/* 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
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